Docker Compose in Production: The Honest Checklist
Run Docker Compose on Ubuntu 24.04 LTS with pinned images, private ports, health checks, HTTPS, backups, and a rollback procedure you can verify yourself.

On this page
- 1. Decide whether a single server meets your recovery needs
- 2. Install Docker and prepare the host
- 3. Define the application contract and save production configuration
- 4. Start the application and verify readiness
- 5. Expose HTTPS through nginx and verify private ports
- 6. Establish persistent storage and prove a restore
- 7. Deploy a replacement and rehearse rollback
- 8. Schedule alerts and perform your first recovery drill
- FAQ
You will have a single-VPS deployment with pinned application images, HTTPS, bounded container resources, and a repeatable update procedure. Docker Compose is suitable for production when you accept brief deployment interruptions and recovery from host failure through backups. It does not provide automatic failover or automatic rollback. Use the steps below to configure those boundaries and verify recovery before accepting production traffic.
1. Decide whether a single server meets your recovery needs
Start with a fresh Ubuntu 24.04 LTS VPS and an application you can package as a container image. This procedure runs a stateless web application behind nginx on the host; database provisioning remains application-specific. You need sudo access, a registry containing your application image, and a domain you control.
Write down how long you can tolerate an outage and how much recently written data you can afford to lose. Compose is a reasonable choice when replacing a failed server from documented configuration and backups fits those limits. A container restart policy cannot recover a failed VPS.
| Your requirement | Decision |
|---|---|
| Brief interruptions during application replacement are acceptable | Use the Compose procedure below. |
| Requests must continue throughout deployments | Add separate application instances and a verified proxy switching procedure. |
| Service must survive the loss of the host | Design deployment and state recovery across independent hosts. |
| You need a deployment interface for several operators | Consider a deployment platform before building your own controls. |
Use the VPS deployment ladder if you are still choosing your operating model. If managing release files feels like the wrong responsibility for your team, compare Coolify, Dokploy and Kamal. When choosing a machine, the Docker hosting guide can help you assess recovery access and storage requirements.
2. Install Docker and prepare the host
Use Docker's package repository so Engine and the Compose plugin come from the same packaging source. The commands below assume a fresh host without another container runtime installed. For an existing server, resolve package conflicts using the official Ubuntu installation instructions before changing packages.
set -eu
sudo apt-get update
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl openssl nginx certbot python3-certbot-nginx
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
printf 'Types: deb\nURIs: https://download.docker.com/linux/ubuntu\nSuites: noble\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' "$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null
sudo apt-get update
sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginEnable Docker and nginx on the actual VPS, where systemd and the host kernel are available. Keep Docker commands behind sudo in this procedure. Membership in the Docker group effectively grants control over the host, so it is not a restricted deployment permission.
set -eu
sudo systemctl enable --now docker nginx
sudo systemctl is-active docker nginx
sudo docker info >/dev/null
sudo docker compose ls3. Define the application contract and save production configuration
Build your image in CI or on a separate build machine, then publish it to your registry. Record its digest and retain the previous accepted image in that registry. Production should pull an identified artifact, without compiling source while the application competes for memory.
The configuration below requires an application that listens on all container interfaces at port 8080, runs as UID and GID 10001, and includes curl. It must serve a readiness endpoint at /healthz, handle termination gracefully, and read its application secret from the path supplied by APP_SECRET_FILE. Implement those requirements in your image before using this configuration; environment variable names alone do not add application behavior.
set -eu
sudo install -d -m 0755 /opt/compose-web
sudo install -d -m 0700 /etc/compose-web/secrets
sudo sh -eu -c 'umask 077; if [ ! -e /etc/compose-web/secrets/app_secret ]; then openssl rand -hex 32 > /etc/compose-web/secrets/app_secret; fi'
sudo chown root:10001 /etc/compose-web/secrets/app_secret
sudo chmod 0440 /etc/compose-web/secrets/app_secret
sudo stat -c '%a %u %g' /etc/compose-web/secrets/app_secretSave the following as /opt/compose-web/compose.yaml, owned by root and readable by your operator account. Create /opt/compose-web/release.env with one assignment: APP_IMAGE= followed immediately by your complete registry image reference pinned with @sha256: and its digest. Keep that file root-owned and make it readable only by root.
name: compose-web
services:
app:
image: "${APP_IMAGE:?Set APP_IMAGE to a digest-pinned image reference}"
restart: unless-stopped
init: true
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
ports:
- "127.0.0.1:8080:8080"
environment:
APP_SECRET_FILE: /run/secrets/app_secret
secrets:
- app_secret
mem_limit: 512m
cpus: "1.0"
pids_limit: 256
stop_grace_period: 30s
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "--max-time", "3", "http://127.0.0.1:8080/healthz"]
interval: 15s
timeout: 5s
retries: 3
start_period: 30s
logging:
driver: local
options:
max-size: "10m"
max-file: "3"
secrets:
app_secret:
file: /etc/compose-web/secrets/app_secretTreat the resource limits as starting values, then adjust them using your application's representative workload. Leave memory available for the host and any database; a memory limit can kill an application that exceeds it. The Compose service reference documents the configuration fields used here.
Compose file-backed secrets remain ordinary files on the host, without automatic encryption at rest. Their source permissions must allow the container's actual user to read them, which is why the generated file uses the application's numeric group. Keep secrets outside Git and exclude them from agent access unless a specific task requires them.
4. Start the application and verify readiness
If your registry is private, authenticate the root Docker client with a token that can only pull the required repository. Use your registry's supported credential procedure and keep the token out of command arguments and logs. Then validate the Compose configuration before starting anything.
set -eu
cd /opt/compose-web
sudo docker compose --env-file release.env config --quiet
sudo docker compose --env-file release.env pull app
sudo docker compose --env-file release.env up -d --wait --wait-timeout 120 app
sudo docker compose --env-file release.env ps
curl --fail --silent --show-error --max-time 10 http://127.0.0.1:8080/healthzMake readiness represent the work you need the service to perform, without returning credentials or internal diagnostics. If you later add a database service, a short depends_on entry only orders startup; use a database health check and condition: service_healthy when initial readiness matters. Your application still needs connection retries after a database restart.
An unhealthy container is not automatically restarted by its restart policy. That policy responds to process exit, while a health check can fail with the process still running. On failure, inspect the application logs and health-check output before changing limits or repeatedly restarting it.
set -eu
cd /opt/compose-web
sudo docker compose --env-file release.env logs --tail 100 app
app_container=$(sudo docker compose --env-file release.env ps -q app)
sudo docker inspect --format '{{json .State.Health}}' "$app_container"5. Expose HTTPS through nginx and verify private ports
Keep the application bound to host loopback and let nginx accept public traffic. Publishing 8080:8080 without the loopback address can expose the application directly, and Docker-published traffic can bypass ordinary UFW filtering. The Docker firewall documentation explains that interaction.
Allow inbound HTTP and HTTPS in your host and provider firewalls while preserving your existing SSH rule. Point your domain's address records at the VPS, including any IPv6 record you publish. Replace app.example.com below with that domain and save the configuration as /etc/nginx/sites-available/compose-web.
server {
listen 80;
listen [::]:80;
server_name app.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Enable the site and request its certificate after replacing the domain and email in the next block. The Certbot user guide covers nginx integration and renewal checks. This basic proxy configuration suits ordinary HTTP applications; configure WebSocket forwarding separately if your application needs it.
set -eu
sudo ln -sfn /etc/nginx/sites-available/compose-web /etc/nginx/sites-enabled/compose-web
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx --non-interactive --agree-tos --redirect --email [email protected] -d app.example.com
sudo systemctl enable --now certbot.timer
sudo certbot renew --dry-run
curl --fail --silent --show-error --max-time 10 https://app.example.com/healthzFrom your laptop, verify that HTTPS works while a connection to the VPS's application port fails. Repeat the private-port check against its public IPv6 address if it has one. Also inspect Compose's port listing: the published application address must remain 127.0.0.1, and databases should have no public port mapping.
6. Establish persistent storage and prove a restore
The example deliberately gives the application no persistent writable storage. If it accepts uploads or maintains a local database, add explicit persistent storage before accepting real writes. Otherwise container replacement can discard data that appeared to survive an ordinary restart.
- For a database container, mount a named volume at the data directory documented for your chosen image.
- For uploaded files, use a dedicated persistent mount or object storage and define how it is backed up.
- For disposable caches, document that deletion is safe and verify the application can reconstruct them.
- For configuration and secrets, keep a recoverable copy outside the VPS with appropriate access restrictions.
A volume is persistence, not a backup. Use the database's supported dump or backup mechanism rather than copying its live data directory and assuming consistency. If uploads and database records must agree, pause writes or use an application-aware procedure that captures a consistent recovery point.
Send encrypted backups outside the server and alert when a scheduled backup is missing or fails. Keep recovery credentials somewhere you can reach after losing the VPS. The VPS backup procedure covers the broader recovery workflow.
Restore into an isolated environment before opening production to users. Give the restored application different credentials and prevent it from sending real notifications or running production jobs. Read a known record and retrieve an associated uploaded file, then record the actual recovery procedure.
7. Deploy a replacement and rehearse rollback
For each update, create /opt/compose-web/next.env with the new digest-pinned APP_IMAGE assignment. Keep it root-owned with restrictive permissions, just like release.env. The commands below assume only the image changes; retain the matching Compose configuration too when a release changes runtime settings.
Take the required backup and resolve database migration compatibility before replacing the application. Prefer additive migrations that both the current and replacement application can use. Run migrations as an explicit release operation, not independently in every application container's startup path.
Use a deployment lock so concurrent operators cannot replace each other's release records. This procedure pulls the candidate before replacing the running container, then records it as accepted only after health checks pass. Expect a brief interruption during replacement.
sudo flock -n /run/lock/compose-web.lock sh -eu -c '
cd /opt/compose-web
docker compose --env-file next.env config --quiet
docker compose --env-file next.env pull app
docker compose --env-file next.env up -d --no-deps --wait --wait-timeout 120 app
curl --fail --silent --show-error --max-time 10 http://127.0.0.1:8080/healthz
cp release.env previous.env
mv next.env release.env
'If the update fails before promotion, release.env still identifies the last accepted image, but the running container may already be the failed candidate. Run the recovery block below to recreate the accepted application. Compose does not undo a failed update automatically.
sudo flock -n /run/lock/compose-web.lock sh -eu -c '
cd /opt/compose-web
docker compose --env-file release.env pull app
docker compose --env-file release.env up -d --no-deps --force-recreate --wait --wait-timeout 120 app
curl --fail --silent --show-error --max-time 10 http://127.0.0.1:8080/healthz
'If a defect appears after promotion, use previous.env in the recovery commands, then copy it to release.env under the same deployment lock after verification. An image rollback does not reverse a database migration or restore changed data. Avoid docker compose down during ordinary updates, and never add volume removal to a deployment script.
8. Schedule alerts and perform your first recovery drill
Create an external HTTPS monitor now and route its alert to someone who can act. Add host alerts for disk exhaustion and memory pressure, plus a check that backups remain recent. Container health is useful evidence, but it cannot tell you whether public DNS or the certificate path works.
Verify that application logs go to standard output and that nginx's host logs also rotate. Retain the current and previous application images when cleaning up disk space. Schedule security updates with a maintenance procedure that includes checking service recovery afterward.
We recommend Claude Code for reviewing configuration changes and investigating redacted operational evidence. Give it a restricted account and specific read commands; broad sudo or Docker socket access defeats that restriction. Use explicit approval for deployments and data changes, following the production agent guardrails.
- Before accepting production traffic, deliberately trigger the external monitor's alert and confirm delivery.
- Reboot the VPS during a planned window, then verify Docker, nginx and the application recover without a manual Compose command.
- Rehearse deploying a replacement image and recovering the accepted image in an isolated environment.
- Record the operator, backup location and exact recovery commands in an operations note accessible outside the VPS.
FAQ
Is Docker Compose suitable for production?
Yes, when the application's availability requirements fit a single host and you maintain a recovery procedure. Compose makes the service definition repeatable, but someone still owns operating-system maintenance and restoration after failure. Choose it because the operating model fits your team. Move beyond it when host-level outages or deployment interruptions exceed what your service can tolerate.
What are the most important Docker Compose production best practices?
Make each release identifiable and recoverable. Pin images by digest, retain accepted artifacts, and keep the configuration associated with each release. Separate durable data from disposable containers and verify a restore outside production. Restrict published ports and administrative access, then add monitoring that observes the service from outside the host. These practices address failures that a successful container start cannot detect.
Can Docker Compose deploy with zero downtime?
A normal replacement of a single application container interrupts service while the replacement starts. Uninterrupted deployment requires extra machinery, such as separate application instances behind a proxy, readiness verification before switching traffic, and connection draining. Database changes must support both application generations during that overlap. A health check or a waiting flag alone does not supply that deployment design.
Does Docker Compose restart unhealthy containers automatically?
A failed health check changes the container's health status, but it does not necessarily terminate the application process. Restart policies act on container exits, so a live process can remain unhealthy indefinitely. Alert on that condition and investigate the failure. If you introduce automated recovery, bound its attempts and preserve diagnostic evidence so repeated restarts do not hide the underlying problem.
Should I run my database in Docker Compose?
You can, provided you understand its storage paths and can restore it independently of the application container. Keep the database port private and budget memory for its workload alongside the rest of the host. Database recovery deserves its own procedure, including migration compatibility. Moving the database to a managed service changes who operates it, but does not remove your responsibility to verify recoverability.
Put this guide in the bigger picture
See how this topic connects to the rest of your product. Our free field guide explains apps, infrastructure and AI, with checklists to help you make business decisions.






