How to Use Docker Compose in Production on a Single Server
Run Docker Compose in production on a single server: restart policies, healthchecks, secrets, a reverse proxy, resource limits, logs, updates and backups.

Many small applications run on one virtual machine with Docker Compose and run fine for years. The difference between a hobby setup and a production one is a short list of settings that nobody notices until a reboot or an upgrade. The guide assumes Docker 27 with Compose v2 on a Linux server, and builds on setting up a VPS for deployments.
A production-minded Compose file
services:
proxy:
image: caddy:2.8
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
depends_on:
app:
condition: service_healthy
app:
image: registry.example.test/shop/app:1.8.3
restart: unless-stopped
env_file: .env.app
expose:
- "8080"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/health"]
interval: 15s
timeout: 3s
retries: 5
start_period: 20s
deploy:
resources:
limits:
memory: 512M
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
networks: [internal, default]
db:
image: postgres:17.2
restart: unless-stopped
env_file: .env.db
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
retries: 6
networks: [internal]
networks:
internal:
internal: true
volumes:
caddy_data:
pgdata:Only the proxy publishes ports. The database sits on an internal network with no route out or in except through the app. Version tags are explicit, memory is bounded and logs rotate, as explained in log rotation and resource limits. The proxy handles TLS automatically; see reverse proxies with Nginx or Caddy.
Secrets
install -m 600 /dev/null .env.app
printf 'DATABASE_URL=postgres://app:change-me@db:5432/app\n' >> .env.app
ls -l .env.appKeep env files out of Git (.gitignore) and readable only by the deploy user. Variables are visible in docker inspect to anyone with Docker access, and are inherited by child processes, so for higher sensitivity use Compose secrets: which mount files in /run/secrets. See env files and secrets.
Updates and rollbacks
cd /srv/shop
docker compose pull
docker compose up -d --remove-orphans
docker compose ps
docker image prune -fChange the version tag in the file, commit it, and apply. Compose recreates only the services that changed. To roll back, restore the previous tag and run up -d again. Test the new version first, with the checks in smoke tests after a deploy. Database migrations that are not backwards compatible make rollback harder, so plan them in two steps.
Start on boot and back up
sudo systemctl enable docker
docker compose config -q && echo "compose file valid"
docker compose exec -T db pg_dump -U app app | gzip > /var/backups/app-$(date +%F).sql.gzWith restart: unless-stopped and Docker enabled at boot, the stack returns after a reboot. Back up databases with their dump tools and volumes as in backing up a Docker volume, copy the backups off the server and practice a restore on a fresh machine.
Restart policies and health checks
On a single server nobody restarts a crashed container but the engine, so the restart policy is the main availability setting. restart: unless-stopped brings services back after a crash or a host reboot while respecting an explicit docker compose stop. Pair it with a healthcheck on each service that has an endpoint or a status command. A health check does not restart anything by itself under plain Compose, but it feeds depends_on with condition: service_healthy, so an application waits for its database to accept connections instead of crashing at start and looping.
Choose check commands that test the real dependency. A check that only confirms the process exists passes while the application returns errors. Hitting a lightweight route that touches the database, with a short timeout and a few retries, matches what a user experiences. Keep the interval modest, since a check every second from every container adds steady load for no benefit.
Networks, ports and what the internet can reach
Publish as few ports as possible. A database should have no ports entry at all: containers on the same Compose network reach it by service name, and nothing outside the host can. Publishing 5432:5432 binds to all interfaces, and Docker adds firewall rules that bypass tools like ufw, which has exposed many databases by accident. When a port must be published for local administration, bind it to loopback with 127.0.0.1:5432:5432.
Put a reverse proxy such as Caddy or nginx in front of the application and let it own ports 80 and 443 and the certificates. The application container then listens only on the internal network. This also gives one place to set request size limits, timeouts and security headers.
Resources, limits and logs
Without limits, one runaway container can starve the others and trigger the kernel's out-of-memory killer, which chooses a victim by its own rules. Set memory limits on every service and size them from observation, not guesswork. Cap log growth with the json-file driver options max-size and max-file, or a busy service will eventually fill the disk, which takes down everything on the host.
Keep named volumes for data and bind mounts for configuration you edit by hand. Back up volumes on a schedule and test a restore, as shown in backing up a Docker volume. Document where each piece of state lives so that rebuilding the server from the repository and a backup is a procedure rather than an investigation.
Deploys with Compose on one host
A basic deploy is docker compose pull followed by docker compose up -d, which recreates only the containers whose image or configuration changed. Pin images to a version tag or a digest, never latest, so a deploy is reproducible and a rollback is a one-line edit. Keep the Compose file and a small deploy script in a repository, and run the script from CI over SSH rather than editing files on the server by hand.
Be honest about the limits. Compose on one host gives short downtime during recreation and no automatic failover; if the machine dies, the site is down until someone restores it. For many teams that trade-off is acceptable and far cheaper than a cluster. When requirements grow to zero-downtime releases or multiple hosts, that is the time to consider an orchestrator, not before.
Plan updates and recovery together. Before a risky change, take a database dump and note the current image tags; if the new version misbehaves, set the previous tag in the file and run up -d again. Keep the host patched, restrict SSH to keys, enable unattended security updates, and put the server behind a firewall that allows only 22, 80 and 443. Compose is the application layer; the operating system around it still needs ordinary care.
Set up monitoring from the start, even a simple external uptime check and a disk space alert. A single-server setup fails in ordinary ways, and the check that tells you first is worth more than any tuning.
Where this does not apply
A single server is a single point of failure: a disk failure or provider outage means downtime until you rebuild. If the service needs zero-downtime deploys, rolling updates across nodes or autoscaling, use an orchestrator. Compose lacks built-in secret rotation and multi-host networking, and its deploy: options are partly Swarm-only. For many such servers, manage them with Ansible. See the Compose in production guide and the Compose file reference.


