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#
| Term | What it is |
|---|---|
| Image | A 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). |
| Container | A 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. |
| Volume | Persistent 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. |
| Port | A container has its own network. To make it reachable from outside, its port is “published” on a port of the VPS. |
| Docker Compose | A 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
apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)Debian 13
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/dockerstays.
Then add the Docker repository and install Docker:
Ubuntu 24.04
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-pluginDebian 13
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-pluginWhat each line does:
| Command | What it does |
|---|---|
apt -y install ca-certificates curl | Certificates for HTTPS and a download tool. |
install -m 0755 -d /etc/apt/keyrings | Creates a directory for repository signing keys with 0755 permissions. |
curl -fsSL …/gpg -o /etc/ | Downloads Docker's signing key. -f = fail on HTTP errors, -s = silent, -S = still show errors, -L = follow redirects. |
chmod a+r …/docker.asc | Everyone can read the key (apt reads it as a different user). |
tee /etc/ | Writes the repository definition to a file. |
Types: deb | A 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: stable | Stable 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:
docker run --rm hello-world
docker compose versiondocker run --rm hello-world– downloads a test image and runs it;--rmdeletes the container when it finishes. You'll seeHello from Docker!.docker compose version– prints the Compose version, for exampleDocker Compose version v5.…. After version 2, Compose jumped straight to 5, so older installations showv2.…. 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#
Create the project directory and switch to it:
bash mkdir -p /opt/wordpress cd /opt/wordpressThe directory name (
wordpress) becomes the Compose project name and the prefix for container and volume names.Create a file with the passwords using
nano .env. Generate the passwords with the password generator (letters and digits only):/opt/ wordpress/ ini.env 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).
Make the file readable by root only:
bash chmod 600 .env
The compose.yaml file#
Create nano compose.yaml with this content:
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:
| Line | What 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:lts | The official MariaDB image; the lts tag = the latest long-term support release. |
restart: unless-stopped | The container starts again after a crash and after a VPS reboot, unless you stopped it yourself. |
MARIADB_DATABASE, MARIADB_USER, MARIADB_PASSWORD | On the first start, creates the database and a user with a password. |
MARIADB_ROOT_PASSWORD | The 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/mysql | Database 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_healthy | WordPress starts only once the database is ready. |
image: wordpress:latest | The 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: db | The database address: the db service name on the project network. |
WORDPRESS_DB_USER, …_PASSWORD, …_NAME | Database credentials; they must match the values of the db service. |
volumes: - wp_data:/var/www/html | WordPress files (plugins, themes, uploaded images) are in the wp_data volume. |
volumes: at the end | Declares the named volumes the project uses. |
Starting it#
docker compose up -d
docker compose psdocker 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.dbshowshealthy,wordpressshowsUp.
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).
| Command | What it does |
|---|---|
docker compose ps | Status of the project's containers. |
docker compose logs -f | Logs of all services; -f = follow live (stop with Ctrl+C). For one service only: docker compose logs -f wordpress. |
docker compose restart | Restarts the containers. |
docker compose stop / start | Stops / starts the containers without deleting anything. |
docker compose up -d | After a change to compose.yaml, recreates only the changed containers. |
docker compose down | Stops and deletes the containers and the network. Volumes with data stay. |
docker compose exec db mariadb -uroot -p | Runs a command inside a running container (here the MariaDB console). |
docker compose config | Validates compose.yaml and prints it with the values from .env filled in (careful, this shows the passwords too). |
Updating to new image versions:
docker compose pull
docker compose up -d
docker image prune -fdocker 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;-fskips 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::
| Value | When the container starts again |
|---|---|
no | Never (default). |
always | Always, even if you stopped it manually (after a Docker or VPS restart). |
on-failure | Only when it exits with an error. |
unless-stopped | Always, 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.
docker system df
docker system prunedocker 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):
{
"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.
Create a directory for backups:
mkdir -p /root/zalohy.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).sqldocker compose exec -T db– runs a command in thedbcontainer;-Tdisables 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_PASSWORDvariable 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.
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_– mounts thewp_ data:/ data: ro wp_datavolume of thewordpressproject 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 lsprints 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 psshows both containers running and the database ashealthy.https://blog.example.czshows 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://(the port is bound to<VPS_ IP_ ADDRESS>: 8080 127.0.0.1only).
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.comrepository, not thedocker.iopackage. - Data belongs in volumes; containers can be deleted and recreated at any time.
- Publish websites on
127.0.0.1and expose them through Nginx with HTTPS; otherwise Docker bypasses UFW. - Updates:
docker compose pullanddocker compose up -d. docker compose down -vandprune --volumesdelete data; back up your volumes regularly.
Further reading#
- Docker: Install Docker Engine on Ubuntu (opens in a new window)
- Docker: Install Docker Engine on Debian (opens in a new window)
- Docker: Packet filtering and firewalls (opens in a new window)
- Docker: Port publishing (opens in a new window)
- Docker: Compose file reference (opens in a new window)
- Docker Hub: wordpress (opens in a new window)
- Docker Hub: mariadb (opens in a new window)