Skip to content

Docker

Docker is the recommended way to deploy a Stadium 8 app. It packages your application and everything it needs into an image that runs on any machine with Docker installed. The server doesn’t need Node.js, and the build happens inside Docker.

Read Before You Deploy first. It covers the environment variables, reverse proxy and cookie settings that apply to every deployment.

The Dockerfile that ships with your project

Section titled “The Dockerfile that ships with your project”

Your project already has a Dockerfile and a .dockerignore at the top of the repository, so you don’t need to create them. Three things to know about them:

  • You build from the project root, not from inside web/. The build only copies web/ into the image, so nothing else in your repository ships.
  • .dockerignore keeps secrets and clutter out of the image. It excludes every .env* file except .env.example, plus node_modules, build output, tests, and the workflow folders (.claude/, documentation/, generated-docs/).
  • The image runs as a non-root user and contains only the standalone build, so it stays small.

If your project uses any NEXT_PUBLIC_* variables, tell the Dockerfile about them. Open the root Dockerfile, find the commented block in the builder stage, directly above RUN npm run build, and uncomment one ARG and ENV pair per variable:

ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL

Add a pair for each further NEXT_PUBLIC_* variable in web/.env.example.

These values are built into the browser code during the build, so you supply them on the docker build command. Docker builds the image in isolation and can’t read your web/.env.local, which is why these values arrive as build arguments instead.

Run these from the top of your project:

Terminal window
# Build: pass one --build-arg per NEXT_PUBLIC_* variable you uncommented.
# The trailing "." is the project root, which is the build context.
docker build \
--build-arg NEXT_PUBLIC_API_BASE_URL="https://api.example.com" \
-t my-app:latest .
# Run: server-side values come in at run time via --env-file, which reads
# web/.env.local from the host. The port is published to loopback only, so
# your reverse proxy can reach it but the internet can't.
docker run -d \
--name my-app \
-p 127.0.0.1:3000:3000 \
--env-file ./web/.env.local \
--restart unless-stopped \
my-app:latest

The values are split between build time and run time for two reasons:

  • NEXT_PUBLIC_* values must exist during the build. Next.js builds them into the JavaScript. Supplying them only at docker run is too late, because the app is already built without them.
  • Server-side secrets must not live in the image. Image layers travel with the image to any registry it’s pushed to. --env-file reads the file from the host at run time, so secrets stay outside the image.

If your platform has a managed secret store, such as AWS Secrets Manager, Azure Key Vault, Kubernetes Secrets or Docker Swarm secrets, use it for the run-time values instead of --env-file.

Docker still needs a reverse proxy in front of it for HTTPS. There are two common set-ups.

An external proxy: run nginx or Caddy on the host and point it at http://127.0.0.1:3000, the container’s published port. You can use the nginx configuration from the Linux guide.

A proxy alongside the app in Docker Compose: add a Caddy service next to the app. Caddy fetches and renews a Let’s Encrypt certificate for you.

docker-compose.yml, at the top of your project:

services:
web:
build:
context: .
args:
# One arg per NEXT_PUBLIC_* variable, as with docker build.
NEXT_PUBLIC_API_BASE_URL: "https://api.example.com"
env_file: ./web/.env.local # server-side values, loaded at run time
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: unless-stopped
volumes:
caddy_data:

Caddyfile:

app.example.com {
reverse_proxy web:3000
}
Terminal window
docker ps # "my-app" should show as Up
docker logs -f my-app # follow the app's logs

Open your site in a browser. It should load over HTTPS without a port number.

If your project uses cookie-based sign-in, read Cookie domains before you go to production.