Build the mental model
A full-stack app splits into at least three pieces: a frontend, an API/backend, and a database — and in production, each piece can live on a different platform, deployed independently.
- Static-vs-dynamic hosting split — which piece is a build output, which is a running process
- Environment variables and secrets — how the pieces find and authenticate to each other
- Production databases — managed, backed up, reached only through a connection string
- CI/CD basics — deploying automatically whenever the relevant code changes
The frontend, if it's a typical React/Vue/Next.js app, is often static or near-static output, deploying the same way as the static site project. The backend is different: a long-running or serverless process that talks to a database and holds secrets, so it deploys to a different kind of platform.
| Where | What lives there |
|---|---|
| Frontend (build time) | A public URL variable like NEXT_PUBLIC_API_URL, baked into the client bundle so the browser knows where the API is. |
| Backend (runtime) | Private variables: DATABASE_URL, JWT_SECRET, third-party API keys — never exposed to the browser. |
Production databases are a managed service
Unlike a local SQLite file or a database in a laptop's Docker container, a production database typically handles backups, connection pooling, and failover for you — the backend just authenticates to it with a connection string.
This is where CI/CD matters again: a working pipeline deploys the frontend and backend automatically whenever their code changes, so "deploy" becomes something that happens the same way on every merge to main.
FULL-STACK APP: FRONTEND + API + DATABASE
-----------------------------------------
[BROWSER]
|
| HTTPS request
v
[FRONTEND] (static/edge host, e.g. a React or Next.js build)
| env: NEXT_PUBLIC_API_URL=https://api.example.com
|
| fetch() to API_URL
v
[API / BACKEND] (separate deploy: container host or PaaS)
| env: DATABASE_URL=postgres://...
| env: JWT_SECRET=...
|
| SQL query over a TLS connection
v
[DATABASE] (managed production database)Connect it to a real scenario
Separate the codebase
A frontend directory and a backend/API directory (or two repos), each independently deployable.
Deploy the backend and set its private env vars
Provision a production database, then set DATABASE_URL, API keys, and signing secrets directly in the platform's dashboard — never in git.
Deploy the frontend and set its public env var
Set the API's public URL using the framework's required public prefix so it bakes into the build.
Wire up CI/CD for both
Connect both deploys to the git repository so a push to main redeploys automatically.
Test the full path in production
Open the live frontend, trigger a request that hits the API and reaches the database — not just a local test.
The #1 full-stack deployment failure
A .env file with all the right values sitting locally, never configured on the actual hosting platform. The app works perfectly on your machine and throws confusing errors — or silently misbehaves — the moment it's live.
Try the working example
function startApp(env, required) {
const missing = required.filter((key) => !env[key] || env[key].trim() === "");
if (missing.length > 0) {
throw new Error(
`Cannot start: missing required environment variable(s): ${missing.join(", ")}`
);
}
console.log("Environment OK. Starting server...");
console.log(` API_URL = ${env.API_URL}`);
console.log(` DATABASE_URL = ${env.DATABASE_URL.replace(/:[^:@]+@/, ":****@")}`);
return true;
}
const required = ["API_URL", "DATABASE_URL", "JWT_SECRET"];
const localEnv = {
API_URL: "http://localhost:4000",
DATABASE_URL: "postgres://user:pass@localhost:5432/app",
JWT_SECRET: "dev-secret",
};
const prodEnvMissingSecret = {
API_URL: "https://api.example.com",
DATABASE_URL: "postgres://user:pass@prod-db.example.com:5432/app",
};
console.log("--- Attempting local config ---");
startApp(localEnv, required);
console.log("\n--- Attempting production config ---");
try {
startApp(prodEnvMissingSecret, required);
} catch (err) {
console.error(err.message);
}--- Attempting local config ---
Environment OK. Starting server...
API_URL = http://localhost:4000
DATABASE_URL = postgres://user:****@localhost:5432/app
--- Attempting production config ---
Cannot start: missing required environment variable(s): JWT_SECRET5-minute try-it
List the environment variables a simple full-stack app (frontend + Express-style API + Postgres database) would need, split into two columns — "frontend, public" and "backend, private" — and for each one, name which platform's dashboard it would actually be set in.
One important caution
Setting an environment variable in a local .env file and forgetting it also needs to be configured, by hand, inside each deployment platform's dashboard.
Using a private secret (like a database password) as a public/client-side environment variable by mistake, exposing it in the frontend's built JavaScript bundle.
The Twelve-Factor App — Config — Cloud & Deployment