SaaS
Self-host a Next.js app on a VPS with Docker
Run a Next.js app with Postgres on your own server: standalone output, a multi-stage Dockerfile, Docker Compose, Caddy for HTTPS, Prisma migrations, low-downtime updates, and backups.
This article contains affiliate links. If you buy through them, Designyff may earn a commission at no extra cost to you.
A Next.js app with Postgres fits on one Linux server. You trade a managed platform’s dashboard for a fixed monthly price, full control, and a few jobs that are now yours: TLS, updates, backups, and the firewall. This guide sets up that server with Docker Compose. It covers the parts that usually go wrong: static files missing from the standalone build, NEXT_PUBLIC_ values baked in at build time, migrations, a database port open to the internet, and backups nobody has tested.
The stack is Next.js in standalone mode, Postgres, and Caddy as the reverse proxy. Caddy gets and renews HTTPS certificates on its own. Prisma handles migrations, as in the Designyff kits.
VPS or managed hosting
Pick a VPS when you want a predictable bill, are comfortable with SSH, and are willing to own patching and backups. Pick a managed platform when you would rather not think about servers. Deploying Next.js with Prisma and Postgres on Railway is the managed version of this guide. It uses the same standalone output and prisma migrate deploy, and Railway runs Postgres and backups for you.
Any VPS provider with a public IPv4 address works, for example DigitalOcean, Hetzner, or Hostinger. Choose a region close to your users. This guide assumes Ubuntu or Debian, a domain whose A record points at the server, and Docker Engine with the Compose plugin.
Step 1: Build Next.js as a standalone server
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
next build then writes .next/standalone/server.js with only the dependencies it traced. The output docs note that public and .next/static are not copied into the standalone folder. If you forget them, the site loads without CSS, client JavaScript, or images. The Dockerfile below copies both.
Add a health route the container and the proxy can call:
// app/api/health/route.ts
export function GET() {
return Response.json({ ok: true });
}
Step 2: A multi-stage Dockerfile
This is a shortened version of the official Next.js Docker example, assuming npm and Prisma:
# Dockerfile
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:24-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_ values are inlined into the browser bundle at build time.
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL
RUN npx prisma generate && npm run build
FROM node:24-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
And a .dockerignore, so local builds and secrets stay out of the image:
node_modules
.next
.git
.env*
Notes:
HOSTNAME=0.0.0.0makes the standalone server listen on all interfaces inside the container. Without it, the proxy container cannot reach it.- The official example pins an exact Node LTS version. Do the same, and bump it on purpose.
- If you use Prisma 6 or older, its query engine needs OpenSSL, which slim Debian images may not include. Add
RUN apt-get update -y && apt-get install -y opensslto the builder and runner stages if Prisma complains aboutlibsslat startup. - Server-only secrets (
DATABASE_URL,STRIPE_SECRET_KEY,AUTH_SECRET) are not build arguments. They are read at runtime from the environment, so the same image works with different secrets. The self-hosting guide explains the build-time vs runtime split.
Step 3: Compose the app, Postgres, and Caddy
# compose.yaml
services:
app:
build:
context: .
args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
env_file: .env
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
migrate:
build:
context: .
target: builder
args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
env_file: .env
environment:
DATABASE_URL: postgresql://app:${POSTGRES_PASSWORD}@db:5432/app
command: ["npx", "prisma", "migrate", "deploy"]
depends_on:
db:
condition: service_healthy
profiles: ["tools"]
db:
image: postgres:18
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
volumes:
pgdata:
caddy_data:
caddy_config:
What each part is for:
- Only Caddy publishes ports.
appanddbare reachable from other containers by service name (app:3000,db:5432), not from the internet. This matters more than it looks. Ports published by Docker are not filtered byufwthe way you might expect (Docker and ufw). Aports: ["5432:5432"]line can expose your database even whenufw statussays 5432 is closed. postgres:18with the volume at/var/lib/postgresql. From version 18, the official image changed its data directory and expects the volume there. Older guides mount/var/lib/postgresql/data, which is correct only for 17 and below (image docs). Pin the major version. A major upgrade needspg_upgradeor a dump and restore, not a new tag.migrateis a one-off service. It uses thebuilderstage, which still has the Prisma CLI. Thetoolsprofile keeps it from starting withdocker compose up.caddy_dataholds certificates. Keep it as a volume so a restart does not request new certificates.
The .env file next to compose.yaml holds the secrets. Compose reads it to fill ${POSTGRES_PASSWORD}, and env_file passes it into the app:
# .env (chmod 600, never committed)
POSTGRES_PASSWORD=change-me-use-openssl-rand-hex-32
NEXT_PUBLIC_APP_URL=https://example.com
AUTH_SECRET=...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
Generate the database password with openssl rand -hex 32. Hex avoids characters that would need escaping inside DATABASE_URL.
Step 4: Caddy for HTTPS
# Caddyfile
example.com {
encode zstd gzip
reverse_proxy app:3000 {
lb_try_duration 30s
}
}
With a public domain name in the site address, Caddy gets a certificate from Let’s Encrypt or ZeroSSL, renews it, and redirects HTTP to HTTPS. It needs the A/AAAA record pointing at the server and ports 80 and 443 open before the first start (automatic HTTPS).
lb_try_duration makes Caddy keep retrying the upstream for up to 30 seconds when it is unreachable, instead of returning a 502 at once. That covers the few seconds when the app container is being replaced during a deploy.
Streaming works without extra config. Next.js recommends turning off proxy buffering for streamed responses, and the self-hosting guide shows an X-Accel-Buffering header for nginx. Caddy flushes responses immediately when the content length is unknown, which covers streamed pages (reverse_proxy docs). Nginx works too. It just needs more lines for the same result, plus a separate certbot setup.
Step 5: First deploy
On the server, from the project directory:
docker compose build
docker compose up -d db
docker compose run --rm migrate
docker compose up -d
docker compose ps
migrate deploy applies migrations that are committed in prisma/migrations. If your project used prisma db push until now, create a baseline migration locally first. The Railway guide shows the migrate dev --name init step, and the same rule applies here.
Then point Stripe’s production webhook at https://example.com/api/stripe/webhook and put that endpoint’s signing secret in STRIPE_WEBHOOK_SECRET. A VPS container is always running, so there is no sleeping service to wake. The handler itself is in Stripe webhooks with Next.js.
Step 6: Updates with little downtime
A deploy script for a single server:
#!/usr/bin/env bash
# deploy.sh
set -euo pipefail
cd /srv/myapp
git pull --ff-only
docker compose build --pull app migrate
docker compose run --rm migrate
docker compose up -d app
docker image prune -f
up -d app replaces the app container. Caddy’s lb_try_duration holds incoming requests while the new container starts, so visitors see a slower response instead of an error page. This is not zero downtime in the strict sense: there is one container, and for a moment nothing is serving. True zero-downtime needs two app containers behind the proxy, plus the multi-instance settings in the self-hosting guide, such as a shared Server Functions encryption key.
The migration runs before the new code starts, so the old code briefly runs against the new schema. Keep migrations backward compatible: add the column in one release, start using it in the next, and drop old columns later.
next build is the most memory-hungry step, and small VPS plans can run out of memory during it. If that happens, build the image in CI, push it to a registry such as GitHub Container Registry, and change the script to docker compose pull app before up -d. The server then only runs images.
--pull also refreshes the Node base image, which is how security fixes in Node and Debian reach your app. Pull caddy:2 and postgres:18 now and then as well.
Step 7: Backups you have restored once
The pgdata volume is not a backup. A bad migration or a DELETE without a WHERE lands in it immediately. Dump the database every night:
#!/usr/bin/env bash
# /srv/myapp/backup.sh
set -euo pipefail
cd /srv/myapp
mkdir -p /srv/backups
docker compose exec -T db pg_dump -U app -d app -Fc > "/srv/backups/app-$(date +%F).dump"
find /srv/backups -name 'app-*.dump' -mtime +14 -delete
# crontab -e
15 3 * * * /srv/myapp/backup.sh
Then copy the dumps off the server with rclone, restic, or your provider’s object storage. A backup on the same disk goes down with the server. Provider snapshots of the whole VPS help too, but they are not a substitute for a database dump you can restore on its own.
Restore one dump into a scratch database, so you know the backups work before you need them:
docker compose exec -T db createdb -U app app_restore_test
docker compose exec -T db pg_restore -U app -d app_restore_test < /srv/backups/app-2026-10-09.dump
docker compose exec -T db psql -U app -d app_restore_test -c 'select count(*) from "User";'
docker compose exec -T db dropdb -U app app_restore_test
Step 8: Basic server hygiene
- SSH with keys only. Set
PasswordAuthentication noin the SSH server config. ufw allow OpenSSH,ufw allow 80,443/tcp,ufw allow 443/udp, thenufw enable. Remember that Docker-published ports bypass these rules, which is why only Caddy publishes any.- Install
unattended-upgradesso the OS gets security patches. - Watch disk space. Docker images, build cache, and backups all grow.
docker system dfshows where it went. - Add an uptime check from outside the server that hits
/api/health.
Deploy checklist
output: "standalone"innext.config.ts, and the Dockerfile copiespublicand.next/static.NEXT_PUBLIC_values passed as build arguments. Secrets passed at runtime through.env.- Only Caddy publishes ports. No
5432or3000on the host. postgres:18pinned, with its volume at/var/lib/postgresql.prisma/migrationscommitted, anddocker compose run --rm migratein every deploy.- DNS points at the server before Caddy’s first start.
- Stripe webhook endpoint on the production domain with its own signing secret.
- Nightly
pg_dump, copied off the server, and one restore tested.
FAQ
Docker or Node directly on the server?
Running node .next/standalone/server.js under systemd works too. Docker makes the Node version part of the image, gives Postgres and the app the same lifecycle, and lets you build anywhere and run the same image on the server.
Can n8n or other services run on the same server? Yes, as more services in the same Compose file, each on its own subdomain in the Caddyfile. Automating SaaS operations with n8n and Stripe webhooks covers what to configure for a self-hosted n8n. Keep an eye on memory, and give each service its own database.
Will the Designyff kits run in this setup unchanged?
That was not verified by running them for this article. The kits expect DATABASE_URL, a Prisma schema, and Stripe environment variables, which is what this Compose file provides. You still add standalone output, the Dockerfile, and a baseline migration yourself.