ChaiBuilder Logo

Deploy to Node / Docker

Self-managing a Node server gives you full control and a fixed cost: a single VPS runs the whole site - builder, CMS, and rendering - with the ISR cache on local disk. The starter ships a production Dockerfile, and a plain pnpm build && pnpm start works anywhere Node 20+ runs.

Option A: plain Node server

On the server (or in CI, shipping the build output to the server):

pnpm install
pnpm run build
pnpm run start        # serves on port 3000

Set the environment variables from Environment & License in the process environment (systemd Environment=, a .env loaded by your process manager, or your platform's secret store).

Keep the process alive with a process manager:

# pm2
pm2 start pnpm --name chaibuilder -- start

# or a systemd unit with ExecStart=pnpm run start, Restart=always

Single instance, local SQLite is fine. A long-running server is the one target where a local SQLite file works in production - it persists on disk. Move to Turso or Postgres before scaling to multiple instances.

Option B: Docker

The starter includes a multi-stage Dockerfile based on Next.js standalone output - it builds the app and produces a small runtime image that starts node server.js as a non-root user on port 3000.

One requirement: standalone output must be enabled in next.config.ts:

const nextConfig: NextConfig = {
  output: 'standalone',
  // …rest of your config
}

Then:

docker build -t my-site .
docker run -d -p 3000:3000 --env-file .env.production my-site

Notes on the shipped image:

  • Based on node:22-alpine with libc6-compat installed (needed by sharp).
  • Detects your package manager from the lockfile - pnpm works out of the box.
  • The ISR cache lives in .next inside the container. It is a cache, not data - losing it on redeploy just means pages rebuild on first visit.

A docker-compose.yml is included for local development with a containerized database; for production compose setups, add your app service plus Postgres and point DATABASE_URL at the database service name.

Reverse proxy and TLS

Put the app behind a reverse proxy that terminates TLS:

server {
  listen 443 ssl;
  server_name www.example.com;

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  }
}
  • Forwarding Host and X-Forwarded-Proto matters - the app derives the site origin from the request, and licensing validates against your domain.
  • Raise client_max_body_size (nginx defaults to 1 MB) so media uploads are not rejected by the proxy before reaching the app.
  • Caddy and Traefik work equally well and handle certificates automatically.

Deploy updates

A standard zero-fuss update flow:

  1. Build the new image (or run pnpm build in a release directory).
  2. Stop the old process/container and start the new one - pending database migrations run automatically on boot (see Database Setup).
  3. First visits after the deploy rebuild pages into the fresh ISR cache.

For zero-downtime, run the new container next to the old one and switch the proxy upstream once it is healthy.

Checklist

  • <input disabled="" type="checkbox"> Node 20.18.1+ (plain Node) or Docker on the host
  • <input disabled="" type="checkbox"> output: 'standalone' set when using the Dockerfile
  • <input disabled="" type="checkbox"> Env vars in the process/container environment
  • <input disabled="" type="checkbox"> Reverse proxy forwarding Host and X-Forwarded-Proto, TLS configured
  • <input disabled="" type="checkbox"> Upload size limit raised on the proxy
  • <input disabled="" type="checkbox"> Object storage configured - the container filesystem is not a media store
  • <input disabled="" type="checkbox"> Database backups scheduled

© ChaiBuilder. All rights reserved.