Docker Compose works well on a single server: the whole application, including the web service, database and proxy, is described in one file and starts with one command. But a file that worked on your laptop causes typical problems in production: logs fill the disk, a container does not come back after a crash, the database is exposed to the internet bypassing the firewall, and an update takes the site down. Below is how to set up Docker Compose in production so that none of this happens, using an "application + PostgreSQL + Caddy reverse proxy" stack as the example.
Tested on Ubuntu 24.04 and 26.04: Docker Engine 29.9.0, Docker Compose 5.6.0, PostgreSQL 18, Caddy 2.11, Miniflux 2.3.3.
Prerequisites
- A server running Ubuntu 24.04 or 26.04 and an
adminuser withsudoprivileges; see initial server setup. - A domain with an A record pointing to the server, for the HTTPS certificate. The examples use
example.comand the address203.0.113.10. - Ports 80 and 443 open in the firewall.
Install Docker Engine and Compose on Ubuntu 24.04 and 26.04
Install Docker from the official Docker repository: this gets you current versions and updates via apt. The docker.io package from the Ubuntu repository and the snap version lag behind and work differently. Ubuntu 26.04 is on the list of supported systems (Docker documentation), and the repository works for it.
First remove packages that conflict with the official ones (if none are installed, apt will say so):
sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | cut -f1)
Add the repository: the signing key goes to /etc/apt/keyrings, the source in .sources format:
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
The Suites line fills in the system codename automatically: noble for 24.04, resolute for 26.04. Check:
sudo docker version --format '{{.Server.Version}}' # 29.9.0
docker compose version # Docker Compose version v5.6.0
sudo docker run --rm hello-world # Hello from Docker!
systemctl is-enabled docker # enabled
To avoid typing sudo before every command, add admin to the docker group and log in to the server again:
sudo usermod -aG docker admin
Keep in mind that the docker group is effectively equivalent to root: through Docker you can mount any system directory into a container. Only add users you would trust with sudo.
Limit Docker log size
By default, Docker writes container output to JSON files with no size limit, and a chatty container can fill the whole disk in a few months. Set up rotation for all containers in /etc/docker/daemon.json:
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
EOF
sudo systemctl restart docker
Now each container gets at most three files of 10 MB. The setting only applies to new containers: recreate existing ones with docker compose up -d --force-recreate. To check what a particular container got:
docker inspect -f '{{.HostConfig.LogConfig}}' <container>
# {json-file map[max-file:3 max-size:10m]}
Restarting Docker also restarts all running containers, so do it before starting the application or during a quiet period.
Project layout: directory, .env and secrets
Keep everything related to the application in one directory:
sudo install -d -o admin -g admin /opt/miniflux
cd /opt/miniflux
As the application, we use Miniflux, an RSS reader: a small image that stores data in PostgreSQL and can check its own health. Your application fits in its place the same way.
Passwords do not go into compose.yaml, since that file usually lives in git. Put them in a .env file next to it, which Compose reads automatically:
umask 077
cat > .env <<EOF
DOMAIN=example.com
POSTGRES_PASSWORD=$(openssl rand -hex 24)
ADMIN_PASSWORD=$(openssl rand -hex 12)
EOF
ls -l .env # -rw------- 1 admin admin
umask 077 creates the file with mode 600, so only the owner can read it. The passwords are random and hexadecimal, so they contain no characters that break the database connection URL. Add .env to .gitignore.
A container's environment variables are visible to anyone with access to Docker, for example in docker inspect. If the image can read the password from a file (for PostgreSQL that is POSTGRES_PASSWORD_FILE), you can pass it via secrets; the file appears in the container under /run/secrets/:
services:
db:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./db_password.txt
For a single server that only you can access, .env with mode 600 is a reasonable minimum.
A production compose.yaml file
The file /opt/miniflux/compose.yaml:
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
caddy:
image: caddy:2.11
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
app:
condition: service_healthy
logging: *logging
app:
image: miniflux/miniflux:2.3.3
restart: unless-stopped
environment:
DATABASE_URL: postgres://miniflux:${POSTGRES_PASSWORD:?not set in .env}@db/miniflux?sslmode=disable
RUN_MIGRATIONS: "1"
CREATE_ADMIN: "1"
ADMIN_USERNAME: admin
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?not set in .env}
BASE_URL: https://${DOMAIN:?not set in .env}
healthcheck:
test: ["CMD", "/usr/bin/miniflux", "-healthcheck", "auto"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
start_interval: 2s
depends_on:
db:
condition: service_healthy
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
logging: *logging
db:
image: postgres:18
restart: unless-stopped
environment:
POSTGRES_USER: miniflux
POSTGRES_DB: miniflux
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?not set in .env}
volumes:
- db_data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U miniflux -d miniflux"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
start_interval: 2s
shm_size: 128mb
deploy:
resources:
limits:
memory: 512M
logging: *logging
volumes:
db_data:
caddy_data:
caddy_config:
And /opt/miniflux/Caddyfile:
example.com {
reverse_proxy app:8080 {
lb_try_duration 30s
}
}
Caddy obtains a Let's Encrypt certificate for the domain on its own and redirects HTTP to HTTPS. Here is what matters in these files for production.
Image versions are pinned. The latest tag means "whatever is current at pull time": the next pull may bring a new major version with breaking changes. For the database, the major version is enough (postgres:18 only receives fixes); for the application, pin the exact version.
${VARIABLE:?message} marks a required variable. If it is missing from .env, Compose refuses to start and prints required variable ADMIN_PASSWORD is missing a value: not set in .env instead of starting the database with an empty password. To view the final configuration with values substituted, run docker compose config.
Data lives in named volumes. The db_data volume survives container removal and recreation. Note the path: starting with PostgreSQL 18, the image stores data in /var/lib/postgresql/18/docker, and the volume is mounted at /var/lib/postgresql rather than …/data as before (details).
x-logging and logging: *logging set up log rotation directly in the project file, repeating the daemon.json setting. With it, the project behaves the same on any server.
Restart after a crash: restart policy
restart: unless-stopped makes Docker restart the container if its process exits, and bring it back after a server reboot. The options:
| Value | When it restarts |
|---|---|
no (default) |
Never |
on-failure |
Only on a non-zero exit code |
always |
Always, including after a manual stop, once Docker restarts |
unless-stopped |
Always, except when you stopped the container yourself |
We tested it: after kill -9 of the application process, Docker brought the container back immediately (RestartCount became 1). Docker treats docker kill and docker stop as a manual stop: the container stays stopped until you run docker compose start or up -d.
Healthcheck and depends_on: condition: service_healthy
depends_on without conditions only guarantees the container start order: the application starts while the database is not yet ready to accept connections. To wait for readiness, the service needs a healthcheck, and the dependent service needs condition: service_healthy.
- For PostgreSQL, use
pg_isready. The-h 127.0.0.1option tests a network connection: while the image initializes a new database, the server listens only on the local socket, and a check without-hmay report readiness too early. - For the application, use its own check. In Miniflux it is
miniflux -healthcheck auto; for your own application, add an endpoint like/healthand check it withcurlorwget, if they are in the image. start_periodis the startup time during which failed checks do not count.start_intervalis how often to check during that period; with it, a container becomeshealthyin a couple of seconds instead of after a fullinterval. Available since Docker Engine 25.
On startup, Compose waits for the checks along the chain:
Container miniflux-db-1 Healthy
Container miniflux-app-1 Started
Container miniflux-app-1 Healthy
Container miniflux-caddy-1 Started
An important limitation: depends_on is Compose logic. After a server reboot, Docker itself starts the containers, all at once and without regard to dependencies. The application must be able to reconnect to the database or exit; in the latter case restart will bring it back.
Ports: only the proxy is public, everything else on 127.0.0.1
In our file, only Caddy publishes ports. app and db have no ports section: other containers in the project reach them by the names app and db, but they are not reachable from outside.
This matters because Docker opens published ports bypassing UFW. We tested it: with UFW enabled and only SSH allowed, port 5433:5432 was reachable from another machine. UFW setup is covered in the initial server setup article. Why container ports are open while UFW is enabled and how to close them is explained in Docker bypasses UFW.
If you need access to a service from the server itself, for example to connect to the database with psql or to put a system-installed nginx in front of the application, publish the port on the local address only:
ports:
- "127.0.0.1:5432:5432"
With this entry, the port listens only on 127.0.0.1 and is closed from outside. From your computer, you can reach it through an SSH tunnel: ssh -L 5432:127.0.0.1:5432 admin@203.0.113.10.
Memory and CPU limits
Without limits, a memory leak in one container causes the kernel to start killing processes across the whole server, and the database may be the first to go. The deploy.resources.limits section works in plain Compose, without Swarm:
deploy:
resources:
limits:
cpus: "1.0" # at most one core
memory: 256M # a process in the container is killed if it exceeds this
The older short form, mem_limit: 256m and cpus: 1.0 at the service level, does the same. Check:
docker stats --no-stream --format "table {{.Name}}\t{{.MemUsage}}\t{{.CPUPerc}}"
NAME MEM USAGE / LIMIT CPU %
miniflux-caddy-1 12.78MiB / 3.825GiB 0.00%
miniflux-app-1 15.09MiB / 256MiB 0.00%
miniflux-db-1 32.58MiB / 512MiB 0.09%
Size the limits from docker stats under real load, with 1.5–2x headroom. The sum of the limits should not exceed the server's memory.
Start and verify
cd /opt/miniflux
docker compose up -d
docker compose ps
db and app should show (healthy) in the STATUS column. Open https://example.com and you will see the login page; the user is admin, the password is in grep ADMIN_PASSWORD .env.
Logs:
docker compose logs -f --tail 100 app # follow the application, Ctrl+C to exit
docker compose logs --since 1h db # database logs for the last hour
PostgreSQL backups in Docker
Do not copy the volume files of a running database: the copy may be inconsistent. The right way is pg_dump inside the database container:
docker compose exec -T db pg_dump -U miniflux -d miniflux -Fc > miniflux.dump
-T disables the TTY; without it, the binary output gets corrupted. -Fc is the compressed custom format, from which pg_restore can restore the database fully or in parts. The daily backup script /opt/miniflux/backup.sh keeps backups for 14 days:
#!/bin/sh
set -eu
umask 077
cd /opt/miniflux
mkdir -p backups
f="backups/miniflux-$(date +%F).dump"
docker compose exec -T db pg_dump -U miniflux -d miniflux -Fc > "$f.tmp"
mv "$f.tmp" "$f"
find backups -name '*.dump' -mtime +14 -delete
The temporary .tmp file ensures that an interrupted dump does not look like a finished backup. Make the script executable, test it and add it to the schedule with crontab -e (daily at 03:30):
chmod +x /opt/miniflux/backup.sh
/opt/miniflux/backup.sh && ls -l /opt/miniflux/backups
30 3 * * * /opt/miniflux/backup.sh
To restore, stop the application, load the backup and start it again:
docker compose stop app
docker compose exec -T db pg_restore -U miniflux -d miniflux --clean --if-exists < backups/miniflux-2026-10-11.dump
docker compose start app
A backup on the same server will not help if you lose the server. Regularly pull backups/ to another machine, for example with rsync over SSH from your computer or a second server. And test a restore on a test copy at least once.
Updating images with minimal downtime
cd /opt/miniflux
docker compose pull
docker compose up -d
docker image prune -f
pull downloads new image versions while the old containers keep running. up -d recreates only the containers whose image or settings changed; the rest stay Running. image prune -f removes old images that nothing references anymore. To upgrade the application to a new version, change the tag in compose.yaml and run the same commands.
With a single application instance, some downtime during an update is unavoidable while the old container stops and the new one starts. You can make it invisible to visitors: the lb_try_duration 30s setting in the Caddyfile makes Caddy retry for up to 30 seconds instead of returning an error right away. We measured a recreation of the application container while sending a request every 0.1 seconds: without this setting there were three 502 responses; with it, not a single error, and the longest request waited about 13 seconds.
Before upgrading the database to a new major version (for example, from 18 to 19), make a backup: data files are incompatible between major versions, and simply changing the tag will not work.
Start Docker Compose on boot
You do not need a separate systemd service for Compose. Two conditions are enough: the Docker service is enabled (systemctl is-enabled docker → enabled, which is the case when installed from the repository), and the containers have restart: unless-stopped. We verified it with a reboot: after 25 seconds all three containers were running, db and app were healthy, and the site responded.
Containers you stopped manually with docker compose stop will not come back after a reboot; that is how unless-stopped behaves, and we verified that too.
Docker disk cleanup
Images, stopped containers and the build cache take up gigabytes over time. First see what uses how much:
docker system df
Safe cleanup commands:
docker image prune -f # untagged images left over after updates
docker image prune -a -f # all images not used by any container
docker builder prune -f # build cache, if you build images on the server
Be careful with volumes. In current Docker versions, docker volume prune and docker system prune --volumes remove only anonymous volumes, but docker volume prune -a also removes named volumes not attached to any container, such as your database if you just stopped the project with docker compose down. Do not run this command on a server with data. For the same reason, do not use docker compose down -v: the -v flag deletes the project's volumes.
Troubleshooting
| Symptom | What to do |
|---|---|
dependency failed to start: container … is unhealthy |
The healthcheck fails. docker inspect --format '{{json .State.Health}}' <container> and docker compose logs <service> show why |
The database does not start: in 18+, these Docker images are configured to store database data in a format… |
The volume is mounted at /var/lib/postgresql/data. For PostgreSQL 18, mount it at /var/lib/postgresql |
required variable … is missing a value |
The variable is missing from .env, or the command was run outside the project directory |
502 from Caddy |
The application is not running or is on the wrong port: docker compose ps, docker compose logs app |
| Caddy does not get a certificate | The domain's A record does not point to the server, or ports 80 and 443 are closed: docker compose logs caddy |
| The database port is reachable from the internet | ports lacks 127.0.0.1:; Docker opens ports bypassing UFW |
| Caddyfile changes did not apply | Reload the configuration: docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile. If your editor replaced the file with a new one instead of modifying it, the container still sees the old version; run docker compose restart caddy |
| The disk is full | docker system df, then the "Docker disk cleanup" section; check log rotation |
Next steps
- If the server is new, start with initial Ubuntu server setup and SSH key authentication.
- If you prefer to run the database on the host rather than in a container: PostgreSQL on Ubuntu 24.04 and 26.04.
- A small service without a database does not need containers: running a program as a systemd service with automatic restarts and logs is shown in the article on a Telegram bot on a server.
- What happens when a container hits its memory limit and how to see it in the logs: OOM killer and out of memory.
- A complete Docker installation with log rotation, version pinning and rootless mode: How to install Docker on Ubuntu and Debian.
- Everyday commands for starting, logs, updating and restarting containers: Docker Compose cheat sheet.
- A reverse proxy in front of containers: Nginx with Let's Encrypt or Caddy; off-server database backups: PostgreSQL backup; monitoring: Uptime Kuma.
- If the disk fills up anyway: how to find and free space; service logs and their size: journald and logrotate.