Docker Compose replaces neither a backup strategy, nor an update policy, nor VPS hardening. What it does provide is a declarative model that makes a stack reproducible: services, images, networks, volumes, ports and restart policies are all described in a file you can review, test and version.
This guide does not cover installing Docker. The engine and the Compose plugin must already be operational, following Install Docker on a Linux VPS. The Docker/UFW pitfall is covered there as well. Here, the goal is to run an application and its database sustainably on a VPS.
The worked example uses the official WordPress and MariaDB images. The principles then apply to Bitwarden, Pi-hole, Jellyfin, Portainer and most stacks made of an application plus a data service.
Three conventions to apply today
1. Do not add a version: key
A modern file starts directly with services:. The top-level version property is kept only for backward compatibility. Docker labels it obsolete, states that it is informative only and notes that using it produces a warning. Compose validates the file against the latest specification regardless. Adding version: "3.8" therefore does not select any particular compatibility engine.
Source: docs.docker.com, version property
2. Use docker compose, as two words
The commands in this guide use the plugin built into the Docker CLI:
docker compose up -d The hyphenated docker-compose command refers to the legacy v1 tool written in Python. Compose v2, announced in 2020, is written in Go and is invoked with docker compose. Compose v5, released in 2025, is functionally identical to v2 on the CLI side: its main addition is an official Go SDK, and the numbering jumped straight to 5 to avoid confusion with the legacy file formats labelled "v2" and "v3". In every case, the form to use remains docker compose.
Source: docs.docker.com, history of Docker Compose
3. Name the file compose.yaml
The canonical name is compose.yaml. The variants compose.yml, docker-compose.yaml and docker-compose.yml remain supported for compatibility, but Docker recommends the canonical name and gives it precedence when several variants sit in the same directory.
Source: docs.docker.com, Compose application model
The architecture used here
The stack respects four simple boundaries:
- the
appservice is the only one publishing a port on the host; - that port listens on
127.0.0.1only, so a reverse proxy installed on the VPS is the single public entry point; - the
dbservice publishes no port and is reachable only through its DNS namedbon the privatebackendnetwork; - data survives container recreation thanks to two named volumes.
Compose creates an internal DNS for services on the same network. The application therefore connects to db:3306, not to a container IP address. A container IP is ephemeral and must never be written into the configuration.
Preparing a working directory
A stack needs a known location, restrictive permissions and a structure you can back up.
sudo install -d -m 0750 -o "$USER" -g "$USER" /opt/stacks/wordpress-prod
cd /opt/stacks/wordpress-prod
umask 077
mkdir -p secrets backups
openssl rand -base64 48 > secrets/db_password.txt
openssl rand -base64 48 > secrets/db_root_password.txt
chmod 600 secrets/*.txt
chmod 700 secrets backups The account running Compose must be able to read both files. They remain in cleartext on the VPS disk: local Compose secrets are handed to the container as files mounted under /run/secrets, but their source is still a local file you have to protect. This mechanism limits their exposure to authorised services only and avoids placing them directly into environment variables. It does not replace a centralised secret manager.
Source: docs.docker.com, using secrets with Compose
The compose.yaml file, commented line by line
The file deliberately starts with services:. It contains no version: key.
services:
# HTTP service for the application.
app:
# The image reference comes from .env and must be validated before production.
image: "${WORDPRESS_IMAGE:?Set WORDPRESS_IMAGE in .env}"
# Restarts on reboot, unless an administrator deliberately stopped the service.
restart: unless-stopped
# Avoids zombie processes if the image does not reap them itself.
init: true
# The database must be reported healthy before the application is created.
depends_on:
db:
condition: service_healthy
# The container's port 80 listens on the VPS loopback interface only.
ports:
- "${APP_BIND_IP:-127.0.0.1}:${APP_PORT:-8080}:80"
# Non-sensitive values can be interpolated from .env.
environment:
WORDPRESS_DB_HOST: "db:3306"
WORDPRESS_DB_NAME: "${DB_NAME:?Set DB_NAME in .env}"
WORDPRESS_DB_USER: "${DB_USER:?Set DB_USER in .env}"
# The official image can read the password from a file.
WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
# Application content persists beyond the container lifecycle.
volumes:
- wordpress_data:/var/www/html
# Only the password the application needs is granted to it.
secrets:
- db_password
# The application receives front-end traffic and talks to the database privately.
networks:
- frontend
- backend
# A local limit keeps logs from filling the VPS disk.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Gives the HTTP server time to close its connections cleanly.
stop_grace_period: 30s
# Database service, with no port published on the host.
db:
# The image reference is managed in .env as well.
image: "${MARIADB_IMAGE:?Set MARIADB_IMAGE in .env}"
# A manual stop is still respected after the daemon or the VPS restarts.
restart: unless-stopped
# Initialises the database and the user on the very first start only.
environment:
MARIADB_DATABASE: "${DB_NAME:?Set DB_NAME in .env}"
MARIADB_USER: "${DB_USER:?Set DB_USER in .env}"
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
# MariaDB data files live in a named volume.
volumes:
- mariadb_data:/var/lib/mysql
# Unlike the application, the database receives both secrets.
secrets:
- db_password
- db_root_password
# This script is provided by the official MariaDB image.
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# The database belongs to the private internal network only.
networks:
- backend
# Same local rotation policy as the application.
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# A database may need time to finish writing.
stop_grace_period: 1m
# Declaration of the persistent storage managed by Docker.
volumes:
wordpress_data:
mariadb_data:
# Declaration of the stack's two network zones.
networks:
frontend:
driver: bridge
backend:
driver: bridge
# Prevents this network from providing direct external connectivity.
internal: true
# Each local secret comes from a separate file on the host.
secrets:
db_password:
file: ./secrets/db_password.txt
db_root_password:
file: ./secrets/db_root_password.txt The official WordPress image supports the WORDPRESS_DB_PASSWORD_FILE convention. The official MariaDB image accepts the MARIADB_*_FILE variants and ships healthcheck.sh. These behaviours belong to the images, not to Compose itself. Always check each image's documentation before transposing this model to another application.
Sources: official WordPress image, official MariaDB image, MariaDB reference for healthcheck.sh
depends_on with condition: service_healthy stops Compose from creating the application before the database health check succeeds. This improves the initial start-up, but does not excuse the application from being able to retry a connection lost during operation. See start-up order in Compose.
The .env file: configuration, not a vault
Compose automatically reads a .env file sitting next to compose.yaml and uses it to interpolate ${VARIABLE} expressions.
# Stable project name. It notably influences the real names of volumes and networks.
COMPOSE_PROJECT_NAME=wordpress-prod
# The service stays local to the VPS and will be published by a reverse proxy.
APP_BIND_IP=127.0.0.1
APP_PORT=8080
# Non-sensitive values passed to both services.
DB_NAME=wordpress
DB_USER=wordpress
# Readable selectors to prepare the first pull.
# Before production, replace them with validated digests as explained below.
WORDPRESS_IMAGE=wordpress:apache
MARIADB_IMAGE=mariadb:lts Source: docs.docker.com, Compose variable interpolation
Image tags are mutable: a publisher can point the same tag at different content. For a strictly reproducible deployment, validate the image then pin its reference with a name@sha256:... digest. Updating the digest then becomes a deliberate, reviewable change. See the Docker best practices on pinning by digest.
To obtain the immutable references matching the images you have just tested:
docker pull wordpress:apache
docker pull mariadb:lts
docker image inspect wordpress:apache --format '{{index .RepoDigests 0}}'
docker image inspect mariadb:lts --format '{{index .RepoDigests 0}}' Copy each full result into the matching variable in .env. Once pinned, docker compose pull will not silently change content: updating goes through an explicit digest change.
The digest pins the image, not the state of the volume. That matters particularly in this example: the official WordPress image notes that WordPress automatic updates can modify the contents of /var/www/html after deployment. A reproducible update policy must therefore cover both the images and the application's own update mechanism.
Even though this example puts no password in .env, that file must not be committed. In many projects it ends up holding a token, a private URL or a production-specific value. A secret written directly into compose.yaml or .env and then pushed to Git stays in the history even after it is removed in the latest commit.
Another important pitfall: the MARIADB_DATABASE, MARIADB_USER and MARIADB_*_PASSWORD_FILE variables are used to initialise an empty data directory. The official image states that they do not reconfigure an existing database. Replacing the contents of a secret file therefore does not automatically change the password stored in MariaDB. Rotation means changing the account in the database, updating the secret the application consumes, then restarting or reloading the components as their documentation requires.
Create a .gitignore file:
.env
secrets/
backups/ You can version a .env.example containing variable names and dummy values only. Protect the real file:
chmod 600 .env Also be careful with docker compose config without options: it prints the resolved configuration and can reveal interpolated values if you have placed secrets in variables. docker compose config --quiet validates without printing anything. See the docker compose config reference.
Named volumes and bind mounts: do not mix them up
A container is replaceable. Any data written only into its internal layer disappears with it. Persistence has to be declared explicitly.
| Criterion | Named volume | Bind mount |
|---|---|---|
| Source | Object managed by Docker | Explicit path on the VPS, for example /srv/app/config |
| Recommended for | Data generated by the application or the database | Configuration file administered from the host, certificate, content the host must handle directly |
| Portability | Loosely coupled to the VPS directory tree | Depends on the path, the permissions and sometimes the host SELinux context |
| Main risk | Forgetting that it exists outside the stack folder | Mounting the wrong path, masking content already present in the image, or granting overly broad write access |
| Backup | Must be exported explicitly | Must be explicitly included in the host path backup |
Docker recommends volumes for persistent data produced by containers. A volume survives the deletion of the container that used it. A bind mount is preferable when an administrator or a host tool must edit a file directly.
In this stack:
wordpress_dataholds/var/www/html;mariadb_dataholds/var/lib/mysql;./secrets/*.txtare host files mounted separately into the authorised services.
Where does a named volume actually live?
The name mariadb_data is the logical name in the Compose model. With COMPOSE_PROJECT_NAME=wordpress-prod, Docker usually creates a name such as wordpress-prod_mariadb_data. Do not build your scripts on that assumption. Compose applies labels to volumes, and docker volume inspect returns their real mount point:
docker volume ls \
--filter label=com.docker.compose.project=wordpress-prod
DB_VOLUME="$(docker volume ls \
--filter label=com.docker.compose.project=wordpress-prod \
--filter label=com.docker.compose.volume=mariadb_data \
--format '{{.Name}}')"
docker volume inspect "$DB_VOLUME" --format '{{.Mountpoint}}' With the local driver and a classic rootful Docker daemon, the path is often under /var/lib/docker/volumes/. That is not a guarantee: rootless mode, a custom data-root or a remote driver change that location. The answer from docker volume inspect is authoritative. Do not edit a database's internal files directly in that directory.
The commands that destroy data
docker compose down removes the stack's containers and networks but keeps named volumes by default. The -v option asks for their removal as well.
Never use
docker compose down -vas an update or troubleshooting command. Verify that you have a restorable backup before deliberately deleting any volume.
Likewise, docker volume prune removes volumes considered unused. A production volume becomes "unused" as soon as its container is deleted, even if its data is still essential. See the docker compose down behaviour.
Publishing ports: the difference between local and public
These two lines are not equivalent:
ports:
- "8080:80" With no host address, Docker publishes the port on every address of the VPS, in practice 0.0.0.0 and, depending on the configuration, IPv6. The service can then become reachable from the internet if routing and network rules allow it.
ports:
- "127.0.0.1:8080:80" Here the port listens on the IPv4 loopback only. That is the right choice when Nginx or Apache runs directly on the VPS and forwards requests to http://127.0.0.1:8080. Docker explicitly documents that omitting the address publishes on all addresses, and that binding to 127.0.0.1 restricts access to the host.
Source: docs.docker.com, port publishing
Check the result, do not just re-read the YAML:
docker compose ps
ss -lntp | grep ':8080'
curl --fail --head http://127.0.0.1:8080 The database has no ports section. The expose directive is not needed for app to reach db on the same Compose network.
Docker manages its own firewall rules, and a published port can bypass the filtering path you expect with UFW. This point is detailed in Secure a Linux VPS: the complete checklist and in the Docker installation guide listed as a prerequisite. The three consistent rules are: do not publish what does not need publishing, bind to 127.0.0.1 behind a local proxy, then verify what is actually listening.
If the reverse proxy is itself a container, 127.0.0.1 means that container, not the host and not the application. In that case connect the proxy and the application to a shared Docker network, without publishing the application port to the internet. For a proxy installed on the host, see Host multiple websites on a VPS with Nginx, then Certbot: install a SSL Let's Encrypt on VPS.
always or unless-stopped when the VPS reboots?
Both policies restart a container after a failure and when the Docker daemon comes back. The difference shows after an administrative stop:
alwaysrestarts a manually stopped container when the Docker daemon restarts;unless-stoppedrespects that manual stop, including after the daemon or the VPS restarts.
For a manually administered stack, unless-stopped avoids bringing back online, at the next reboot, a service you had deliberately stopped for maintenance. always suits a service that must always come back and where that behaviour is explicitly wanted. The policy is no substitute for a health check: it reacts to the main process stopping, not to an application that is alive but stuck.
Source: docs.docker.com, restart policies
Validating and starting the stack
Start by checking the CLI:
docker compose version
docker info Then validate and deploy from the stack directory:
cd /opt/stacks/wordpress-prod
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=100 --timestamps docker compose pull downloads images but does not replace running containers. docker compose up -d then compares the desired state with the existing one, creates what is missing and recreates services whose image or configuration has changed. Mounted volumes are preserved through that recreation. See the docker compose up behaviour.
After the first start, also check exposure from another machine. A successful local test does not prove that port 8080 is unreachable publicly.
Updating without losing data
A production update is a small change procedure, not a command run blindly.
Before the update
- Read the release notes for the application and the database, especially migrations and supported upgrade paths.
- Check disk space and inodes with
df -handdf -i. - Take a backup and test its restoration regularly.
- Note the image references currently deployed with
docker compose images. - Change the exact tags or digests in
.envonly after validation. - Run
docker compose config --quiet.
Applying the update
cd /opt/stacks/wordpress-prod
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --since=10m --timestamps What may be recreated:
- the
appcontainer if its image reference or configuration changes; - the
dbcontainer if its image reference or configuration changes; - the networks if their definition changes.
What is preserved:
- the contents of the
wordpress_dataandmariadb_datanamed volumes; - the VPS files used as bind mounts or secret sources;
- the previous images, as long as they have not been deleted.
docker compose restart is not enough after changing compose.yaml or the variables: it restarts existing containers without applying the new configuration. Use docker compose up -d. See the docker compose restart reference.
A recreation can cause a short interruption. Compose on a single VPS does not promise a zero-downtime update. For a critical application, plan a strategy suited to the application, a second instance or an orchestration platform.
Rolling back
For an application rollback, put the previous image reference back into .env, then run pull and up -d again. This method does not undo a schema migration. If the new version changed the database in an incompatible way, only a rollback plan documented by the publisher, or a validated restore, will get you back cleanly. That is precisely why the backup comes before the update.
Back up the stack, not just its YAML
Copying compose.yaml backs up none of the data held in the volumes. Conversely, copying MariaDB's internal files while it is running does not guarantee a consistent database. For this case, combine:
- a logical MariaDB dump produced by the database's own tool;
- an archive of the application volume taken while the application is stopped;
- a copy of
compose.yaml,.envand the list of image references; - a separate, encrypted backup of the secret files;
- a copy off the VPS, with retention and restore tests.
MariaDB documents mariadb-dump in its official container backup guide.
A consistent backup example
Run this block with Bash from the stack directory. The application is stopped to prevent writes during the dump and the archive. The database stays up for the duration of the logical dump. The trap restarts the application even if a command fails.
set -Eeuo pipefail
cd /opt/stacks/wordpress-prod
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
DEST="backups/$STAMP"
install -d -m 0700 "$DEST"
docker compose config --quiet
docker compose stop app
trap 'docker compose start app' EXIT
docker compose exec -T db sh -c '
exec mariadb-dump \
--user=root \
--password="$(cat /run/secrets/db_root_password)" \
--single-transaction \
--routines \
--triggers \
--events \
"$MARIADB_DATABASE"
' | gzip -9 > "$DEST/database.sql.gz"
docker compose run --rm --no-deps -T \
--entrypoint tar app \
-C /var/www/html -czf - . \
> "$DEST/wordpress-data.tar.gz"
cp compose.yaml .env "$DEST/"
docker compose images > "$DEST/images.txt"
gzip -t "$DEST/database.sql.gz"
tar -tzf "$DEST/wordpress-data.tar.gz" > /dev/null
(cd "$DEST" && sha256sum database.sql.gz wordpress-data.tar.gz compose.yaml .env images.txt > SHA256SUMS)
docker compose start app
trap - EXIT This script deliberately does not copy secrets/ into the same archive. Export those files to a vault or an encrypted backup with separate access control. A backup that exists only on the same VPS disappears along with the disk, the account or the incident that destroys production.
A dump with --single-transaction suits transactional tables. An application using other engines or several storage systems requires a consistency procedure defined by its publisher. For large volumes and strict recovery objectives, also look into physical backup tools, replication and coordinated snapshots.
If you would rather not script and supervise this chain yourself, the automatic backup option available with our Linux VPS plans takes a daily copy in a separate datacenter, with a rotating history and one-click restore. It does not remove the need to test an application-level restore, but it covers the case where the VPS itself is lost.
Testing a restore
A file whose restore has never been tested is only a backup hypothesis. The test must run in an isolated project, with empty volumes and the same image references as the backup:
- copy
compose.yaml,.envand the test secrets to another VPS or to an isolated directory; - change
COMPOSE_PROJECT_NAMEand the host port so production is untouched; - create the application volume by running a one-off command, then extract the archive;
- start
dbonly, wait for itshealthystate, then import the dump into the empty database; - start
app, check the business functions and record the real recovery time.
Example import into a test database that is already initialised and empty:
gzip -dc backups/DATE/database.sql.gz | \
docker compose exec -T db sh -c '
exec mariadb \
--user=root \
--password="$(cat /run/secrets/db_root_password)" \
"$MARIADB_DATABASE"
' Do not run this import against the existing production database. A complete restore procedure must state how to obtain an empty database, what downtime is acceptable, and how to return to the previous state if validation fails.
Reading logs and diagnosing a restart loop
The first mistake is usually to run down straight away. That deletes the containers and loses part of the useful state information. Start by observing.
docker compose ps --all
docker compose logs --tail=200 --timestamps app
docker compose logs --tail=200 --timestamps db
docker compose logs --follow --since=10m app The logs command can filter by service, follow the stream and limit history with --tail or --since. See the docker compose logs reference.
Then inspect the container's exact state:
CID="$(docker compose ps -q app)"
docker inspect "$CID" --format \
'status={{.State.Status}} exit={{.State.ExitCode}} oom={{.State.OOMKilled}} restarts={{.RestartCount}} error={{.State.Error}}'
docker compose top
docker stats --no-stream
df -h
df -i
free -h
journalctl -u docker --since '30 minutes ago' Common causes are:
- a secret missing, unreadable or mounted under the wrong name;
- the database still unavailable, mismatched credentials or a failed migration;
- a host port already in use;
- a volume mounted in the wrong place, or permissions incompatible with the container user;
- a process killed for lack of memory, visible as
OOMKilled=true; - a full disk or exhausted inodes;
- an image incompatible with the VPS architecture;
- an interpolated configuration different from the one you expected.
Validate the model without printing values, then list the expected variables:
docker compose config --quiet
docker compose config --variables If the service restarts too fast to allow exec, launch a one-off container without its dependencies and with a shell, provided the image ships one:
docker compose run --rm --no-deps --entrypoint sh app That one-off container mounts the same volumes and secrets declared for the service. Avoid making any change until you understand the cause.
The json-file rotation declared in the file limits the space local logs consume. Docker notes that max-size defaults to -1, meaning unlimited. For a substantial production setup, also ship logs to an external system with suitable retention. See the json-file logging driver.
Operational checks worth keeping
On every change
docker compose config --quiet
docker compose up -d
docker compose ps
docker compose logs --since=10m --timestamps Before every update
- release notes and migration path read;
- disk space checked;
- backup taken, exported off the VPS, restore already tested;
- current images noted;
- maintenance window announced if needed.
After every VPS reboot
docker compose ps
docker compose logs --since=30m --timestamps
ss -lntp Check that the application is healthy, that the database is not published, that the application port is still bound to 127.0.0.1 and that the reverse proxy serves the domain over HTTPS.
Common mistakes to avoid
- starting the file with
version: "3.8"; - using the legacy
docker-composebinary; - naming the file
docker-compose.ymlin a new project; - writing
8080:80while assuming the service stays local; - publishing
3306:3306when only the application needs to reach the database; - storing passwords in
compose.yamlor in the Git repository; - using a bind mount for a database without controlling ownership, permissions, backup and security context;
- believing that deleting a container deletes or backs up its volume;
- running
docker compose down -vto "start clean"; - running
docker compose restartexpecting a new configuration to be applied; - updating a database without reading its migration path or having a way back;
- backing up only to the VPS disk;
- mistaking a
runningcontainer for a genuinely healthy application.
Frequently asked questions
Do I still need to write version in a Compose file?
No. The top-level version property is documented as obsolete: it is purely informative and produces a warning. Compose interprets the file with the current specification whatever value you write.
What is the difference between docker compose and docker-compose?
Hyphenated docker-compose is the Python v1 tool. docker compose as two words is the Go plugin, v2 since 2020 and v5 since 2025. Only the second form should be used today.
Does docker compose down delete my data?
Not by default: named volumes are kept. The -v option deletes them, as does docker volume prune on a volume no container still uses.
How do I update a stack without losing the database?
Take a backup, change the image reference in .env, then run docker compose pull followed by docker compose up -d. Containers are recreated, named volumes are kept. docker compose restart is not enough: it does not re-read the configuration.
What VPS configuration suits a Compose stack?
A web application and its database fit on 2 vCPUs and 4 GB of RAM. Budget more memory as soon as you add a cache, a search engine or several stacks on the same machine, and above all watch disk space: images, volumes and logs pile up faster than expected.
Conclusion
A reliable Compose stack rests less on the amount of YAML than on a few invariants: a compose.yaml file that follows the current specification, images chosen and updated deliberately, identified volumes, ports published only where strictly needed, secrets kept out of the repository, a restorable backup and diagnostics based on the containers' real state.
The normal cycle then becomes predictable: validate, back up, pull the images, apply with docker compose up -d, check health, and keep the ability to roll back. That discipline is what turns a Compose example into an operating foundation for the stacks that follow.
