Deploy a full-stack app (React + API + Postgres)

A React frontend and a separate Express + Postgres API — each its own app, the way a real app is built.

Production apps keep the frontend and the API as separate services, each deployed and scaled on its own. On Dockhold that's two apps with two URLs, and the API gets its own managed database. Running more than one app is a Pro feature — this recipe is the full chain.

Two ready-made templates: the API (Express + Postgres) and the web frontend (React). Click Use this template on each to get your own copies, then follow the order below.

The shape

  • API app — Express, with a managed Postgres database, at its own URL.
  • Web app — a React (Vite) single-page app at its own URL, calling the API.

They have to learn each other's URL: the web app needs the API's URL, and the API needs the web app's URL to allow it through CORS. Both are dashboard variables — so deploy both apps, then set the two URLs and restart.

Your app must listen on 0.0.0.0 and read its port from the PORT environment variable — never localhost, never a hardcoded port. Dockhold assigns PORT at runtime; an app that ignores it can't receive traffic.

1. Deploy the API (with a database)

  1. Use the fullstack-api template, then deploy it.
  2. On the deploy form, check Add a managed database. Dockhold injects DATABASE_URL.
  3. When it's live, copy its URL — something like https://fullstack-api-xxxx.dockhold.app.

The API creates its table on startup and stores guestbook messages in Postgres, so they persist across restarts and deploys.

2. Deploy the web frontend

  1. Use the fullstack-web template, then deploy it. It builds from its Dockerfile and goes live at its own URL.
  2. In the dashboard, set the API_URL variable to the API URL from step 1, then restart the web app. It's read at runtime, so there's no rebuild — the URL is injected when the app starts.

3. Allow the frontend through CORS

Back on the API app, open the Variables tab, set ALLOWED_ORIGIN to the web app's URL, and restart. Now the browser is allowed to call the API, and the guestbook works end to end.

Why two apps?

You could cram everything into one process, but real apps keep the frontend and API separate so they deploy, scale, and fail independently. That's two apps — available on Pro. On the free plan you can run one of them; the second is the upgrade.

Lock the API down to your app

By default the API is public — anyone with the URL can call it (CORS only restrains browsers, not scripts). To require a token on every request, make the API a private app: on the API app's Access tab, switch it to Private and mint an access token. Now every request must carry Authorization: Bearer <token>, and anything without it is rejected at the edge.

Don't put that token in the React app. A browser app ships all of its code to every visitor, so a token baked into it is readable in the browser's developer tools — that only slows down bots, it doesn't keep anyone out. A secret in a frontend is not a secret.

To genuinely restrict the API to your app, keep the token on a server the browser can't read — put a small server in front of the API that holds the token and forwards requests (a "backend for frontend"):

  • Use a Next.js frontend instead of a static SPA: a server route handler forwards /api/* to the private API, adding the Authorization header from a token stored in your Vault. The browser talks only to your own server (same origin, no token), and your server talks to the API.
  • Or front the static build with a small server (e.g. an Express app that serves the files and proxies /api/* to the private API with the token).

Either way the token lives only on the server, never in the browser — so only your app can reach the API. Keep the token in the Vault (not a plain or VITE_ variable) so it's encrypted and injected at runtime.

Troubleshooting

  • Frontend loads but calls fail with a CORS error: set ALLOWED_ORIGIN on the API to the exact web app URL and redeploy (step 3).
  • Frontend says "API_URL is not set": set the API_URL variable in the dashboard and restart the web app.
  • API returns 500 on the database: make sure you enabled the managed database so DATABASE_URL is injected.

Next