# How to Deploy a Full-Stack App with a Database
A full-stack app has three moving parts that all have to find each other: a frontend, a backend API, and a database. Getting them deployed and correctly wired — without committing secrets or fighting CORS — trips up a lot of developers. This tutorial walks through the architecture and a clean deployment.
The architecture
We will deploy a typical setup:
Browser → Frontend (React/Vite static) → API (Node/Express container) → PostgreSQLThree deployable units:
- 1Frontend — a static SPA (or SSR app). Built once, served from the edge.
- 2Backend — a containerized API that talks to the database.
- 3Database — a managed PostgreSQL instance.
The key decision is keeping the database private — only the backend should reach it — and exposing only the frontend and the API to the internet.
Step 1: prepare the database layer
Your backend should read the database connection string from an environment variable, never hardcode it:
// db.js
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});Keep schema changes in versioned migrations so any environment can be rebuilt deterministically:
-- migrations/001_init.sql
CREATE TABLE todos (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Step 2: containerize the backend
A clean, production Dockerfile reads the port from the environment:
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=8080
EXPOSE 8080
CMD ["node", "server.js"]Run migrations on startup or as a release step so the schema is always current before traffic arrives:
// run on boot, before listening
await runMigrations(pool);
app.listen(process.env.PORT || 8080);Step 3: build the frontend
The frontend needs to know where the API lives. Inject the API base URL at build time:
# Vite reads VITE_-prefixed vars at build
VITE_API_URL=https://api.myapp.com npm run buildconst api = import.meta.env.VITE_API_URL;
fetch(`${api}/todos`).then(r => r.json());The build output (dist/) is static files — serve them from a CDN, not a server.
Step 4: handle CORS
Because the frontend and API are on different hostnames, the API must allow the frontend's origin:
import cors from 'cors';
app.use(cors({ origin: 'https://myapp.com', credentials: true }));Misconfigured CORS is the single most common full-stack deploy failure. Set the exact origin, not *, when using credentials.
Step 5: wire the environment variables
Here is the full wiring map. Nothing sensitive lives in git.
| Component | Variable | Value |
|---|---|---|
| Backend | DATABASE_URL | managed Postgres connection string |
| Backend | PORT | injected by platform |
| Backend | CORS_ORIGIN | https://myapp.com |
| Frontend (build) | VITE_API_URL | https://api.myapp.com |
Step 6: deploy in the right order
- 1Database first — provision and run migrations.
- 2Backend next — deploy with
DATABASE_URLset; confirm/healthreturns OK. - 3Frontend last — build with the live API URL and deploy.
- 4Domains + SSL — attach
myapp.comto the frontend andapi.myapp.comto the backend; let the platform provision certificates.
A simple health check the platform can poll:
app.get('/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true }); }
catch { res.status(503).json({ ok: false }); }
});Common pitfalls
- Database publicly exposed. Keep it private; only the backend connects.
- Secrets in the frontend bundle. Anything in the client build is public. Only the API URL belongs there, never DB credentials.
- Forgetting migrations in CI. Schema drift between environments causes mysterious 500s.
- CORS wildcards with credentials. Browsers reject
*+ credentials.
Doing it on PandaStack
This is the workflow PandaStack is built around. Connect your repo and it auto-detects the frontend framework and the backend (Dockerfile or buildpacks), builds both, and goes live. Provision a managed PostgreSQL and PandaStack auto-wires it — DATABASE_URL is injected into your backend, so step 1's connection string is handled for you. Custom domains get automatic SSL, you get live build/app logs to debug the first deploy, plus rollbacks and deploy history if something goes wrong. The free tier includes 5 web services, 5 static sites, and 1 database — enough for a complete full-stack app.
References
- [PostgreSQL node-pg connection docs](https://node-postgres.com/features/connecting)
- [MDN: Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [Vite environment variables](https://vitejs.dev/guide/env-and-mode.html)
- [The Twelve-Factor App: config](https://12factor.net/config)
---
Full-stack deploys are about ordering and wiring, not magic. PandaStack auto-wires your database and builds frontend and backend from one git push — start free at [dashboard.pandastack.io](https://dashboard.pandastack.io).