The Docker image
The Dockerfile builds a production image: Next.js's standalone server with Payload, on Node 22 (Alpine), running as a non-root user. The image holds no secrets and no site address, so one image runs anywhere; everything is set when the container starts.
Build #
docker build -t my-site .
Building needs about 4 GB of memory for Next.js. A small server can run the image but may fail to build it: build in CI or on a bigger machine, push the image to a registry, and pull it on the server.
The build loads the Payload config, so it sets a placeholder secret and database address inside the build stage only. NEXT_PUBLIC_SERVER_URL is left unset on purpose: set at build time, Next.js would bake it into the image.
Run #
docker run -p 3000:3000 \
-e DATABASE_URL=mongodb://your-database:27017/my-site \
-e PAYLOAD_SECRET=… \
-e NEXT_PUBLIC_SERVER_URL=https://www.example.com \
-v my-site-uploads:/app/uploads \
my-site
| Setting | |
|---|---|
DATABASE_URL | The database. Required. |
PAYLOAD_SECRET | Signs logins. Required. Keep it the same across deploys, or everyone is logged out. |
NEXT_PUBLIC_SERVER_URL | The site's public address, for canonical links, hreflang, the sitemap and SEO previews. Read when the container starts. |
/app/uploads | Uploaded files, one folder per upload collection: uploads/media for the media library, uploads/translation-files for Translate a file. Mount one volume here, or uploads are lost when the container is replaced. |
PORT | Defaults to 3000. |
| Optional services | Email, AI translation, the assistant connection, the newsletter, maps and background jobs: see Environment variables. |
The image starts the server with HOSTNAME=0.0.0.0, so it listens on every interface. Keep that if you write your own start command: Docker and Kubernetes set HOSTNAME to the container's name, and with a named host Next.js builds its rewrites as absolute addresses that loop as redirects.
Health check #
GET /api/health answers 200 with the running version while the server and the database are up, and 503 when the database does not answer:
{ "status": "ok", "version": "0.3.0" }
The image's HEALTHCHECK uses it, and so can your platform. It is public and says nothing beyond the version.
Try it locally #
docker-compose.yml has the development database and, behind a profile, the image itself:
docker compose --profile app up -d --build # http://localhost:3000
It builds the image, runs it next to the mongo service, keeps uploads in a named volume, and reads PAYLOAD_SECRET (and NEXT_PUBLIC_SERVER_URL if set) from .env.
Smoke-test an image #
node scripts/docker-smoke.mjs http://localhost:3000 https://www.example.test
The first argument is where the container answers, the second the NEXT_PUBLIC_SERVER_URL it runs with. The script waits for the health check, creates the first admin, uploads an image (which tests sharp), publishes a page with sanitised HTML (which tests jsdom) and checks that the page renders with the right canonical address. It is plain Node 22 with no dependencies, and CI runs it against every build of the image.
Run it against an empty database, such as a fresh deployment before anyone has logged in: a site that has an admin already refuses to make another.
- The admin it creates is the site's first, with a random password that the script prints once. Keep it, or log in with it, make your own admin and delete this one. (Deleting the only admin would open the first-user signup again, so the script leaves it.)
- The image and the page it publishes stay. Delete them in the admin when you no longer need them.