Skip to content
Current coupon:10% offUse it in your order

Docker and Docker Compose on a VPS: installation and your first app

Install Docker from the official repository, run WordPress with MariaDB using Docker Compose, and learn how to manage, update, back up and firewall your containers.

  • advanced
  • ~25 min
  • Updated 1 Oct 2026

Docker runs apps in isolated containers that bring everything they need with them. We'll install it from the official repository, use Docker Compose to run WordPress with a database, and go through management, updates, the firewall and backups.

What you'll need

  • A StormHost VPS with Ubuntu 24.04 or Debian 13
  • An SSH connection as root (guide)
  • For WordPress with HTTPS, a domain pointed at the VPS
  • About 25 minutes

How it works#

TermWhat it is
ImageA ready-made app template: system libraries, the program and its settings. It's downloaded from a registry, most often Docker Hub (for example wordpress, mariadb).
ContainerA running instance of an image. It has its own file system and network but shares the kernel with the VPS. You can delete a container at any time and create it again from the same image.
VolumePersistent storage for data outside the container. Data in a volume survives deleting and updating the container. Everything you want to keep must be in a volume.
PortA container has its own network. To make it reachable from outside, its port is “published” on a port of the VPS.
Docker ComposeA tool that starts a whole multi-container app (for example a website and a database) from a single compose.yaml file.

Installation#

Install Docker from Docker's official repository. The docker.io package from the distribution repository tends to be older and conflicts with the official packages.

First remove any conflicting packages. On a fresh VPS the command finds nothing, which is fine.

Ubuntu 24.04

bash
apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)

Debian 13

bash
apt remove $(dpkg --get-selections docker.io docker-compose docker-doc docker-buildx podman-docker containerd runc | cut -f1)
  • dpkg --get-selections … – lists which of the given packages are installed.
  • | cut -f1 – keeps only the package names.
  • apt remove $(…) – uninstalls them. Data in /var/lib/docker stays.

Then add the Docker repository and install Docker:

Ubuntu 24.04

bash
apt update
apt -y install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
tee /etc/apt/sources.list.d/docker.sources <<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
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Debian 13

bash
apt update
apt -y install ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

What each line does:

CommandWhat it does
apt -y install ca-certificates curlCertificates for HTTPS and a download tool.
install -m 0755 -d /etc/apt/keyringsCreates a directory for repository signing keys with 0755 permissions.
curl -fsSL …/gpg -o /etc/apt/keyrings/docker.ascDownloads Docker's signing key. -f = fail on HTTP errors, -s = silent, -S = still show errors, -L = follow redirects.
chmod a+r …/docker.ascEveryone can read the key (apt reads it as a different user).
tee /etc/apt/sources.list.d/docker.sources <<EOF … EOFWrites the repository definition to a file.
Types: debA repository with binary packages.
URIs:The repository address for your system.
Suites: $(…)The system's codename (noble for Ubuntu 24.04, trixie for Debian 13), filled in automatically.
Components: stableStable releases only.
Architectures: $(dpkg --print-architecture)The CPU architecture (amd64).
Signed-By:Packages must be signed with this key, otherwise apt rejects them.
apt -y install docker-ce …Installs Docker Engine (docker-ce), the command line (docker-ce-cli), the container runtime (containerd.io), image building (docker-buildx-plugin) and Compose (docker-compose-plugin).

After installation Docker starts on its own and is enabled at VPS boot. Check the installation:

bash
docker run --rm hello-world
docker compose version
  • docker run --rm hello-world – downloads a test image and runs it; --rm deletes the container when it finishes. You'll see Hello from Docker!.
  • docker compose version – prints the Compose version, for example Docker Compose version v5.…. After version 2, Compose jumped straight to 5, so older installations show v2.…. The commands in this guide work with both.

The docker group = root privileges

Anyone in the docker group can start containers with access to the whole VPS disk, which effectively means root privileges. Only add people you would also give root to. If you work as root, you don't need the group.

First app: WordPress with MariaDB#

Project and passwords#

  1. Create the project directory and switch to it:

    bash
    mkdir -p /opt/wordpress
    cd /opt/wordpress

    The directory name (wordpress) becomes the Compose project name and the prefix for container and volume names.

  2. Create a file with the passwords using nano .env. Generate the passwords with the password generator (letters and digits only):

    /opt/wordpress/.envini
    DB_PASSWORD=<DATABASE_PASSWORD>
    DB_ROOT_PASSWORD=<DATABASE_ROOT_PASSWORD>
    • DB_PASSWORD – password of the WordPress database user.
    • DB_ROOT_PASSWORD – password of the root user inside the database container (for backups and administration).
  3. Make the file readable by root only:

    bash
    chmod 600 .env

The compose.yaml file#

Create nano compose.yaml with this content:

/opt/wordpress/compose.yamlYAML
services:
  db:
    image: mariadb:lts
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      start_period: 10s
      interval: 10s
      timeout: 5s
      retries: 3

  wordpress:
    image: wordpress:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}
      WORDPRESS_DB_NAME: wordpress
    volumes:
      - wp_data:/var/www/html

volumes:
  db_data:
  wp_data:

Line by line:

LineWhat it does
services:The list of the app's containers. The service name (db, wordpress) also serves as its hostname on the project network.
image: mariadb:ltsThe official MariaDB image; the lts tag = the latest long-term support release.
restart: unless-stoppedThe container starts again after a crash and after a VPS reboot, unless you stopped it yourself.
MARIADB_DATABASE, MARIADB_USER, MARIADB_PASSWORDOn the first start, creates the database and a user with a password.
MARIADB_ROOT_PASSWORDThe database root password, set on the first start.
${DB_PASSWORD}The value is filled in from the .env file in the same directory, so the passwords aren't written directly in compose.yaml.
volumes: - db_data:/var/lib/mysqlDatabase data is stored in the db_data volume, so it survives container updates.
healthcheck:Every 10 s Docker uses the healthcheck.sh script from the MariaDB image to check that the database accepts connections and is initialized.
depends_on: db: condition: service_healthyWordPress starts only once the database is ready.
image: wordpress:latestThe official WordPress image (Apache + PHP).
ports: - "127.0.0.1:8080:80"The container's port 80 is available on port 8080 of the VPS, but only from the VPS itself (127.0.0.1). From outside it can only be reached through Nginx.
WORDPRESS_DB_HOST: dbThe database address: the db service name on the project network.
WORDPRESS_DB_USER, …_PASSWORD, …_NAMEDatabase credentials; they must match the values of the db service.
volumes: - wp_data:/var/www/htmlWordPress files (plugins, themes, uploaded images) are in the wp_data volume.
volumes: at the endDeclares the named volumes the project uses.

Starting it#

bash
docker compose up -d
docker compose ps
  • docker compose up -d – downloads the images, creates the network, volumes and containers and starts them in the background (-d, detached).
  • docker compose ps – lists the project's containers and their status. db shows healthy, wordpress shows Up.

Check from the VPS: curl -I http://127.0.0.1:8080 returns WordPress's response headers (a redirect to the installer).

MARIADB_* variables only apply on the first start

The database is created on the first start with an empty volume. Changing the password in .env later doesn't change it in the database. Change the password in the database with ALTER USER (see MariaDB on a VPS) and only then in .env.

Exposing it through Nginx and HTTPS#

WordPress now listens only on 127.0.0.1:8080. Expose it through Nginx as a reverse proxy following the Nginx and HTTPS guide: in the configuration use proxy_pass http://127.0.0.1:8080; and your domain, for example blog.example.cz, then get a certificate with certbot --nginx -d blog.example.cz.

The WordPress image detects HTTPS on its own from the X-Forwarded-Proto header, which Nginx sends when set up as in that guide. Finally, open https://blog.example.cz and finish the WordPress installation in your browser.

Managing containers#

Run docker compose commands in the project directory (cd /opt/wordpress).

CommandWhat it does
docker compose psStatus of the project's containers.
docker compose logs -fLogs of all services; -f = follow live (stop with Ctrl+C). For one service only: docker compose logs -f wordpress.
docker compose restartRestarts the containers.
docker compose stop / startStops / starts the containers without deleting anything.
docker compose up -dAfter a change to compose.yaml, recreates only the changed containers.
docker compose downStops and deletes the containers and the network. Volumes with data stay.
docker compose exec db mariadb -uroot -pRuns a command inside a running container (here the MariaDB console).
docker compose configValidates compose.yaml and prints it with the values from .env filled in (careful, this shows the passwords too).

Updating to new image versions:

bash
docker compose pull
docker compose up -d
docker image prune -f
  • docker compose pull – downloads new image versions.
  • docker compose up -d – recreates containers that have a newer image. Data in volumes stays.
  • docker image prune -f – deletes old images that are no longer used; -f skips the confirmation.

docker compose down -v deletes data

The -v flag of docker compose down also deletes the project's volumes, meaning the database and the WordPress files. This can't be undone. Only use it when you want to remove the app completely and you have a backup.

Options for restart::

ValueWhen the container starts again
noNever (default).
alwaysAlways, even if you stopped it manually (after a Docker or VPS restart).
on-failureOnly when it exits with an error.
unless-stoppedAlways, except when you stopped it yourself. Recommended for services.

Ports and the firewall#

Docker publishes container ports with its own firewall rules that bypass UFW. A port published as 8080:80 is reachable from the internet even if you don't allow it in UFW. The UFW firewall guide explains this in detail.

Entry in ports:Who can reach the port
"8080:80"Anyone on the internet, regardless of UFW.
"127.0.0.1:8080:80"Only processes on the VPS (for example Nginx). Recommended for websites behind Nginx.
no ports:Only other containers on the same project network (typically databases).

Don't publish databases and other internal services at all. Restrict ports that need to be public (for example a game server in a container) with the firewall in the customer area (guide). It sits in front of the VPS and Docker can't bypass it.

Freeing up disk space#

Images, stopped containers and logs take up a lot of space over time.

bash
docker system df
docker system prune
  • docker system df – how much space images, containers, volumes and the cache take.
  • docker system prune – after confirmation, deletes stopped containers, unused networks, untagged images and the build cache. It doesn't delete volumes with data unless you add --volumes.

Be careful with deleting volumes and with -a

docker system prune --volumes also deletes unused anonymous volumes (unnamed ones, created for example by docker run without -v). If they held data of a container that is currently deleted or stopped, that data is gone. docker volume prune -a also deletes named volumes such as wordpress_db_data when no container uses them, for example after docker compose down. docker system prune -a deletes all images that no container uses. Before confirming, read what the command says it will delete.

By default, container logs aren't rotated and can fill up the disk. Set the local driver, which removes old logs on its own (by default it keeps at most 5 files of 20 MB per container). Create the file nano /etc/docker/daemon.json with the content below. If the file already exists, don't overwrite it; just add the "log-driver" line (entries separated by commas):

/etc/docker/daemon.jsonJSON
{
  "log-driver": "local"
}

Then restart Docker (systemctl restart docker) and recreate the containers (docker compose up -d --force-recreate). The setting only applies to newly created containers.

Backups#

Back up volumes, not containers. You can recreate a container from its image, but not the data.

  1. Create a directory for backups: mkdir -p /root/zalohy.

  2. Back up the database with an export (more consistent than copying files):

    bash
    cd /opt/wordpress
    docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction --all-databases -uroot -p"$MARIADB_ROOT_PASSWORD"' > /root/zalohy/wp-db-$(date +%F).sql
    • docker compose exec -T db – runs a command in the db container; -T disables the terminal so the output can be redirected to a file.
    • sh -c '…' – the command runs in the container's shell, where the $MARIADB_ROOT_PASSWORD variable is set.
    • mariadb-dump --single-transaction --all-databases – exports all databases without locking.
    • > /root/zalohy/… – the output is saved on the VPS, outside the container.
  3. Back up the WordPress files by archiving the volume:

    bash
    docker run --rm -v wordpress_wp_data:/data:ro -v /root/zalohy:/backup alpine tar czf /backup/wp-files-$(date +%F).tar.gz -C /data .
    • docker run --rm – starts a one-off container that is deleted when it finishes.
    • -v wordpress_wp_data:/data:ro – mounts the wp_data volume of the wordpress project read-only (ro).
    • -v /root/zalohy:/backup – mounts the backup directory from the VPS.
    • alpine tar czf … -C /data . – packs the volume's contents into an archive using the small Alpine image.

    docker volume ls prints the exact volume names.

Running backups automatically with cron and copying them off the VPS is covered in Backups on a VPS.

Verification#

  • docker compose ps shows both containers running and the database as healthy.
  • https://blog.example.cz shows WordPress with a padlock in the address bar.
  • After a VPS reboot (reboot) the containers start on their own.
  • From another computer you can't connect to http://<VPS_IP_ADDRESS>:8080 (the port is bound to 127.0.0.1 only).

Troubleshooting#

permission denied while trying to connect to the Docker daemon socket#

You aren't root and your user isn't in the docker group. Run the command with sudo, or log in as root.

Bind for 127.0.0.1:8080 failed: port is already allocated or address already in use#

Port 8080 is already used by another container (first message) or another program on the VPS (second message). Find out which one (ss -tlnp | grep 8080, docker ps) and pick a different port in compose.yaml, for example 127.0.0.1:8081:80. Then update proxy_pass in Nginx too.

WordPress shows “Error establishing a database connection”#

The database isn't ready yet or the credentials don't match. Check the database log (docker compose logs db). If you changed the password in .env after the first start, the database still has the old password (see the warning in Starting it).

The container keeps showing Restarting#

The app in the container crashes right after starting. You'll find the cause in the log: docker compose logs --tail 50 <SERVICE_NAME>.

toomanyrequests: You have reached your unauthenticated pull rate limit#

Docker Hub limits anonymous image pulls to 100 per 6 hours per IP address. Wait, or log in with your Docker Hub account: docker login.

no space left on device#

The disk is full. Find out what's using it (docker system df, df -h) and clean up as described in Freeing up disk space. If you're genuinely out of space, increase RAM in the Change plan tab; disk size grows with memory.

Summary

  • Install Docker from the official download.docker.com repository, not the docker.io package.
  • Data belongs in volumes; containers can be deleted and recreated at any time.
  • Publish websites on 127.0.0.1 and expose them through Nginx with HTTPS; otherwise Docker bypasses UFW.
  • Updates: docker compose pull and docker compose up -d.
  • docker compose down -v and prune --volumes delete data; back up your volumes regularly.

Search the guides