LinuxAugust 31, 2026 25 views

Docker Compose on a Linux VPS: deploy and maintain a stack in production

Docker Compose on a Linux VPS: deploy and maintain a stack in production

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 app service is the only one publishing a port on the host;
  • that port listens on 127.0.0.1 only, so a reverse proxy installed on the VPS is the single public entry point;
  • the db service publishes no port and is reachable only through its DNS name db on the private backend network;
  • 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.

CriterionNamed volumeBind mount
SourceObject managed by DockerExplicit path on the VPS, for example /srv/app/config
Recommended forData generated by the application or the databaseConfiguration file administered from the host, certificate, content the host must handle directly
PortabilityLoosely coupled to the VPS directory treeDepends on the path, the permissions and sometimes the host SELinux context
Main riskForgetting that it exists outside the stack folderMounting the wrong path, masking content already present in the image, or granting overly broad write access
BackupMust be exported explicitlyMust 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_data holds /var/www/html;
  • mariadb_data holds /var/lib/mysql;
  • ./secrets/*.txt are 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 -v as 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:

  • always restarts a manually stopped container when the Docker daemon restarts;
  • unless-stopped respects 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

  1. Read the release notes for the application and the database, especially migrations and supported upgrade paths.
  2. Check disk space and inodes with df -h and df -i.
  3. Take a backup and test its restoration regularly.
  4. Note the image references currently deployed with docker compose images.
  5. Change the exact tags or digests in .env only after validation.
  6. 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 app container if its image reference or configuration changes;
  • the db container if its image reference or configuration changes;
  • the networks if their definition changes.

What is preserved:

  • the contents of the wordpress_data and mariadb_data named 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, .env and 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:

  1. copy compose.yaml, .env and the test secrets to another VPS or to an isolated directory;
  2. change COMPOSE_PROJECT_NAME and the host port so production is untouched;
  3. create the application volume by running a one-off command, then extract the archive;
  4. start db only, wait for its healthy state, then import the dump into the empty database;
  5. 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-compose binary;
  • naming the file docker-compose.yml in a new project;
  • writing 8080:80 while assuming the service stays local;
  • publishing 3306:3306 when only the application needs to reach the database;
  • storing passwords in compose.yaml or 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 -v to "start clean";
  • running docker compose restart expecting 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 running container 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.

Related articles