DEPLOYMENT

Running Docker containers in production: a practical checklist

By , FounderUpdated 9 min read

The checklist, in one place. The rest of this page shows how to do each item.

  • Every service has a restart policy and the Docker service starts at boot.
  • Healthchecks on the app and the database.
  • Data lives in named volumes, with database dumps copied off the server and a tested restore.
  • Memory and CPU limits on each service.
  • Log rotation for every container.
  • Only the reverse proxy publishes ports; the database sits on an internal network.
  • Image versions pinned; secrets in env files, not in images.
  • An update procedure that keeps the previous version ready for rollback.

A production compose.yaml to start from

This example runs a Node.js app, PostgreSQL, and Caddy as the reverse proxy (Caddy gets and renews HTTPS certificates on its own). It uses Docker Compose v2, the docker compose plugin that ships with Docker Engine. If you are on the old docker-compose Python tool, install Docker Engine from the official Docker repository first.

compose.yaml
name: myapp

x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  proxy:
    image: caddy:2.10-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks: [web]
    depends_on:
      app:
        condition: service_healthy
    logging: *default-logging

  app:
    image: ghcr.io/you/myapp:1.4.2
    restart: unless-stopped
    env_file: ./app.env
    environment:
      NODE_ENV: production
      PORT: "3000"
    networks: [web, backend]
    depends_on:
      db:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 512M
    logging: *default-logging

  db:
    image: postgres:17-alpine
    restart: unless-stopped
    env_file: ./db.env
    volumes:
      - pgdata:/var/lib/postgresql/data
    networks: [backend]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "1.0"
          memory: 1G
    logging: *default-logging

networks:
  web:
  backend:
    internal: true

volumes:
  pgdata:
  caddy_data:
  caddy_config:
Caddyfile
example.com {
    reverse_proxy app:3000
}

Containers on the same network reach each other by service name, which is why the Caddyfile says app:3000 and the app connects to the database at host db. The $$ in the database healthcheck stops Compose from substituting the variable itself, so the shell inside the container does it.

Restarts and healthchecks

restart: unless-stopped brings a container back after it crashes and after the server reboots, but leaves it down if you stopped it on purpose. always also works; the difference is only whether a manual docker compose stop survives a reboot. Make sure the Docker service itself starts at boot:

terminal
sudo systemctl enable docker

A healthcheck tells Docker whether the process is actually working, not just running. In the example, Caddy waits until the app is healthy and the app waits for PostgreSQL to accept connections, which removes the classic "app crashed because the database wasn't up yet" loop on boot.

Outside Swarm, Docker does not restart a container just because it is marked unhealthy. The status shows in docker compose ps and is used by depends_on, but restarts only happen when the process exits. Have your app exit when it is truly broken, and monitor health from outside as well.

Point the check at a cheap endpoint such as /health, and make sure the tool it calls exists in the image. Alpine-based images have wget through BusyBox; slim Debian images often have neither wget nor curl.

Keep data in volumes, and back it up

Anything written inside a container's own filesystem disappears when the container is recreated, which happens on every image update. Databases, uploads and certificates must live in named volumes (as pgdata and caddy_data do above) or bind mounts.

A volume is not a backup. It sits on the same disk as everything else. For PostgreSQL, take logical dumps with pg_dump rather than copying the data folder of a running database:

terminal
mkdir -p backups
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
  > backups/db-$(date +%F).dump

For other volumes, such as an uploads volume for user files, archive them with a throwaway container:

terminal
docker run --rm \
  -v myapp_uploads:/data:ro \
  -v "$PWD/backups":/backup \
  alpine tar czf /backup/uploads-$(date +%F).tar.gz -C /data .

Compose prefixes volume names with the project name, hence myapp_uploads; check with docker volume ls. Run the backups from cron or a systemd timer, and copy the files off the server. A backup that only exists on the machine that failed is no help.

Then test a restore, into a scratch database so you don't touch live data:

terminal
docker compose exec -T db createdb -U app restore_test
docker compose exec -T db pg_restore -U app -d restore_test --no-owner < backups/db-2026-10-03.dump
docker compose exec -T db psql -U app -d restore_test -c '\dt'
docker compose exec -T db dropdb -U app restore_test

Replace app with your POSTGRES_USER. If the tables and row counts look right, you have a backup. Until then you have a file.

Cap memory, CPU and log size

Without limits, one leaking container can use all the server's memory and the kernel starts killing processes, possibly your database. docker compose applies deploy.resources.limits on a normal single host, not only in Swarm. The older service-level keys mem_limit and cpus do the same job; use one style, not both. Check what each container actually uses with docker stats and set limits with some headroom above that.

For Node.js, keep the heap below the container limit so the app hits its own limit before the kernel kills it, for example NODE_OPTIONS=--max-old-space-size=384 under a 512 MB limit.

Logs are the other slow disk-filler. Docker's default json-file driver keeps logs forever unless you set a size. The x-logging block above caps each container at three 10 MB files. To make that the default for every container on the host, set it in the daemon config:

/etc/docker/daemon.json
{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

Restart Docker with sudo systemctl restart docker. The setting only applies to containers created afterwards, so run docker compose up -d --force-recreate for existing ones.

Expose only the proxy (and the ufw gotcha)

In the example only Caddy has a ports: section. The app and database have none, so nothing outside Docker can reach them. The database is also on the backend network marked internal: true, which has no route to the outside world at all.

Published Docker ports bypass ufw. Docker writes its own iptables rules for published ports, and that traffic never reaches ufw's rules, so ports: ["5432:5432"] exposes PostgreSQL to the internet even if ufw status says port 5432 is denied.

If you need to reach a service from the host itself (for an admin tool or an SSH tunnel), bind it to loopback:

compose.yaml (excerpt)
db:
    ports:
      - "127.0.0.1:5432:5432"

Check what is really listening with sudo ss -tlnp, and test from another machine. The rest of the server hardening (SSH keys, firewall, updates) is in our Linux server security checklist.

Pin image versions and keep secrets out of images

postgres:latest means whatever was newest the day the server pulled it. A routine docker compose pull can then jump a major version, and a PostgreSQL major upgrade needs a data migration, not just a new image. Pin at least the major version (postgres:17-alpine), and pin your own app to an exact tag (1.4.2 or a Git commit SHA). For full repeatability, add the digest: postgres:17-alpine@sha256:…, which guarantees the same bytes every time.

Secrets go in env files on the server, never in the Dockerfile or the image. Anything you COPY or set with ENV at build time can be read by anyone who can pull the image.

db.env
POSTGRES_USER=app
POSTGRES_PASSWORD=a-long-random-password
POSTGRES_DB=app
app.env
DATABASE_URL=postgres://app:a-long-random-password@db:5432/app
SESSION_SECRET=another-long-random-string

Lock them down with chmod 600 db.env app.env. Add both files to .gitignore and .dockerignore. If you want secrets out of environment variables entirely, Compose also supports file-based secrets:, and the official PostgreSQL image reads POSTGRES_PASSWORD_FILE.

Update and roll back safely

Keep compose.yaml in Git, so every version change is a commit. An update is then: change the tag, pull, recreate, and watch.

terminal
# 1. Back up first if this release changes the database
docker compose exec -T db sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' \
  > backups/pre-1.4.3.dump

# 2. Change the tag in compose.yaml: myapp:1.4.2 -> myapp:1.4.3, then
docker compose pull app
docker compose up -d --wait app

# 3. Check
docker compose ps
docker compose logs --tail 100 -f app

up -d only recreates containers whose image or settings changed, and --wait returns once they report healthy, or fails if they don't. There is a short gap while the app container is replaced; for no gap at all you need two app containers behind the proxy, updated one at a time.

To roll back, set the tag back to 1.4.2 and run docker compose up -d app. The old image is still on the server, so this takes seconds, as long as you don't run docker image prune -a straight after an update. Prune old images once the new version has proven itself. If the new release ran a database migration, rolling back the image may not be enough; that is what the pre-update dump is for.

Where NevaraCloud fits

If you would rather hand all of this over, we run containers for customers as managed Docker hosting. There is no fixed price list: we look at what your containers need and quote in LKR. For a single Next.js or Node.js app, our Next.js hosting guide shows when you don't need containers at all.

If you run Docker on your own server and want a second pair of hands, our deployment service sets up or fixes existing setups, quoted after we look at it. We don't sell self-managed VPS servers, so the server itself would stay yours.

Rather we handled it?

Managed hosting billed in LKR, Docker hosting, or hands-on deployment help. Tell us what you're working on.

Ask us on WhatsApp