Contents

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_URLThe database. Required.
PAYLOAD_SECRETSigns logins. Required. Keep it the same across deploys, or everyone is logged out.
NEXT_PUBLIC_SERVER_URLThe site's public address, for canonical links, hreflang, the sitemap and SEO previews. Read when the container starts.
/app/uploadsUploaded 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.
PORTDefaults to 3000.
Optional servicesEmail, 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.

These guides ship with Tolk Starter 0.6.0: buyers get the same pages in their repository, next to the code they describe.