Install Docker Engine properly
On Debian 12 or Ubuntu 24.04, add Docker's repository and install the engine plus the Compose plugin:
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
. /etc/os-release
sudo curl -fsSL https://download.docker.com/linux/$ID/gpg -o /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/$ID $VERSION_CODENAME stable" | \
sudo tee /etc/apt/sources.list.d/docker.list
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
docker compose version
On AlmaLinux and Rocky 9, add https://download.docker.com/linux/rhel/docker-ce.repo with dnf config-manager --add-repo and install the same package names. Then sudo systemctl enable --now docker.
Adding your user to the docker group saves typing sudo, but membership is equivalent to root. Only do it for accounts you would give root anyway.
Daemon defaults that matter
Create /etc/docker/daemon.json before you start any containers:
{
"log-driver": "local",
"log-opts": { "max-size": "20m", "max-file": "5" },
"live-restore": true
}
sudo systemctl restart docker
The default json-file driver has no size limit. A chatty container will happily write 15 GB of logs onto a 20 GB disk and stop the database alongside it. The local driver compresses and rotates. live-restore keeps containers running while the daemon itself restarts during upgrades. Log options only apply to containers created after the change, so recreate existing ones.
A production-shaped compose.yaml
services:
app:
image: ghcr.io/example/api:1.8.2
restart: unless-stopped
env_file: .env
ports:
- "127.0.0.1:3000:3000"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 15s
timeout: 3s
retries: 3
deploy:
resources:
limits:
memory: 1g
db:
image: postgres:16
restart: unless-stopped
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
retries: 5
shm_size: 256m
volumes:
pgdata:
secrets:
db_password:
file: ./secrets/db_password
Why it looks like that
- Pinned tags:
:latestmeans a reboot can pull a different version than the one you tested. Pin a version and change it deliberately. - 127.0.0.1 on ports: the app is reachable only from the host, where nginx terminates TLS. The database publishes nothing; the app reaches it by service name on the internal network.
- restart: unless-stopped: containers come back after a reboot or crash but stay down if you stopped them on purpose.
- Health checks with depends_on: the app waits until Postgres accepts connections, rather than crash-looping through its first thirty seconds.
- Memory limit: a leak in one service gets that container killed, not the whole instance.
- Secrets file: the password never appears in
docker inspectoutput or process listings.
Run, inspect, upgrade
docker compose up -d
docker compose ps
docker compose logs -f --tail=100 app
docker stats --no-stream
docker compose ps should show healthy next to both services within a minute. To upgrade, change the tag and run:
docker compose pull app
docker compose up -d app
Only the changed service is recreated. Keep the previous tag written down; rolling back is the same two commands with the old version.
Volumes and backups
Named volumes live under /var/lib/docker/volumes/. Copying that directory while Postgres is running does not give you a consistent backup. Dump from inside the container instead:
docker compose exec -T db pg_dump -U postgres -Fc appdb > /srv/backup/appdb-$(date +%F).dump
Then ship the file off the instance. The backups guide covers where to and how often.
Housekeeping
docker system df
docker image prune -a --filter "until=168h"
Every upgrade leaves the previous image on disk. On a 40 GB volume, a few months of weekly deploys adds up. Prune images older than a week on a systemd timer, and never run docker volume prune casually: it deletes unattached volumes, which may include data you meant to keep.
Sizing
A small API with Postgres and Redis runs on Boost. Once you add a worker, a search index or a second app, Turbo gives four vCPUs and 8 GB, which leaves real room for the page cache that keeps the database fast. Image pulls and builds are disk-heavy, so NVMe-backed storage shortens deploys as well as queries.