If you have ever tried Docker, you're probably familiar with long docker run commands packed with -p, -v, -e, --name, and --network options. For a single container, that's still manageable. But what if your application is made of three, four, or five containers that depend on each other?
Imagine you want to run WordPress. You need one container for WordPress, another for the database, a network so they can talk to each other, and volumes so the data doesn't disappear. All of that has to be started in the right order, and repeated every time you move to a new server. One typo, and the app won't start.
This is where Docker Compose comes in. With Docker Compose, the whole container setup lives in a single YAML file and starts with a single command.
In this article, we'll cover Docker Compose from top to bottom: what it is, how to install it on Ubuntu, the structure of a compose.yaml file, a hands-on WordPress + MariaDB example, the commands you'll use most, and a firewall pitfall that's easy to miss on a VPS. Let's get started!
What is Docker Compose?
Docker Compose is a tool for defining and running applications made of multiple containers. All the configuration, from images, ports, volumes, and networks to environment variables, is written in a YAML file usually named compose.yaml.
Here's a simple analogy: if docker run is ordering dishes from the waiter one at a time, Docker Compose is handing over the complete order list at once. The kitchen (Docker) then prepares everything in the right order.
Docker Compose has a bit of history worth knowing, because plenty of old tutorials are still around:
- Compose V1 was written in Python and run with the
docker-composecommand (with a hyphen). It has not been developed for a long time. - Compose V2 and later were rewritten in Go as a Docker CLI plugin, so the command became
docker compose(with a space). At the time of writing, the latest version is already at v5.x.
Note: if you find a tutorial that uses
docker-compose up, just change it todocker compose up. Most of the YAML file content remains compatible.
There's one more change that often confuses people: the version: "3.8" line at the top of the file. According to the official Compose documentation, the version field is now obsolete and purely informational. Compose always validates against the latest schema and will actually show a warning if the line is still there. So in new files, you can simply leave it out.
If you're new to containers, images, and volumes, it's a good idea to read Getting to Know Docker Containers before continuing.
Why Use Docker Compose?
Here are a few reasons Docker Compose deserves a spot in your toolbox:
- One file, one source of truth: the entire application configuration lives in
compose.yaml. No more keeping longdocker runcommands in a separate notes file. - One command for everything:
docker compose up -dstarts every container,docker compose downstops them. - Automatic networking: Compose creates a dedicated network for each project, so containers can reach each other simply by service name, such as
db. - Easy to move: copy the project folder to another server and run the same command. The result is identical.
- Git-friendly: since it's a text file, configuration changes can be tracked, reviewed, and rolled back when something goes wrong.
Drawbacks of Docker Compose
To be fair, Docker Compose has its limits too:
- Designed for a single host: Compose manages containers on one machine. To manage many servers at once, you need an orchestrator such as Kubernetes or Docker Swarm.
- No automatic failover between servers: if the server goes down, every container on it goes down too.
- Requires YAML knowledge: YAML is very sensitive to indentation. One wrong space can make the file invalid.
For a personal blog, internal tools, development environments, or self-hosting on a single VPS, these limitations are barely noticeable.
Preparation
Before you start, make sure you have:
- A server or computer running Ubuntu 24.04 LTS or 26.04 LTS (64-bit). The commands in this article are intended for both versions. According to the Docker documentation, Ubuntu 22.04 LTS is also supported.
- A user with
sudoaccess. - At least 1 GB of RAM for the WordPress + MariaDB example. If memory is tight, consider adding swap as described in Understanding Swap Memory in Linux.
Tip: if you're using a freshly rented VPS, secure it first by following How to Secure a New Linux VPS. We'll also look at how Docker interacts with the UFW firewall at the end of this article.
How to Install Docker and Docker Compose on Ubuntu
On Ubuntu, the recommended approach is to use Docker's official repository. The Compose package is available there as docker-compose-plugin, so we can install Docker Engine and Compose together.
Step #1: Remove Old Docker Packages
Ubuntu ships unofficial Docker packages (docker.io, docker-compose, and so on) that can conflict with the official ones. Remove them first with the following command:
sudo apt remove docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runcIf you see a message saying these packages aren't installed, that's normal. It just means your server is still clean.
Step #2: Add Docker's Official Repository
First, install the supporting packages and download Docker's GPG key:
sudo apt update
sudo apt install ca-certificates curl -y
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.ascNext, register the Docker repository. This command automatically adapts to your Ubuntu version and CPU architecture, so you can copy it as is:
sudo 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
EOFThen refresh the package list:
sudo apt updateStep #3: Install Docker Engine and the Compose Plugin
Now install Docker Engine together with the Compose plugin:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -yOnce it's done, make sure Compose is installed:
docker compose versionThe output should look roughly like this:
Docker Compose version v5.7.0The version number may differ depending on when you install it. What matters is that the docker compose command (with a space) is recognized.
Step #4: Run Docker Without sudo (Optional)
By default, docker commands must be run with sudo. To let your user run them directly, add the user to the docker group:
sudo usermod -aG docker $USERLog out and log back in (or close and reopen your SSH session) for the group change to take effect. Test it by running a sample container:
docker run --rm hello-worldIf you see the Hello from Docker! message, Docker is working correctly.
Warning: members of the
dockergroup effectively have root-level privileges on that server. Only add users you truly trust. If in doubt, skip this step and keep usingsudoin front of everydockercommand.
Understanding the compose.yaml File Structure
Before the hands-on part, let's get to know the main parts of a Compose file. According to the Compose application model documentation, the preferred file name is compose.yaml. Older names such as docker-compose.yml are still supported for backward compatibility.
Here's the simplest possible Compose file:
services:
web:
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
restart: unless-stoppedHere's what each key does:
| Key | Purpose | docker run equivalent |
|---|---|---|
services |
List of containers (services) that make up the app | — |
web |
Service name, also the hostname on the internal network | --name |
image |
Image to use | image argument |
ports |
Maps host:container ports |
-p |
volumes |
Attaches folders or volumes to the container | -v |
environment |
Environment variables for the container | -e |
restart |
Automatic restart policy | --restart |
depends_on |
Startup order between services | — |
Besides services, there are two top-level keys you'll see often:
volumes: defines named volumes, persistent storage managed by Docker. Data inside them survives even when the container is removed.networks: defines custom networks. If you leave it out, Compose automatically creates a default network for your project.
Now, let's get our hands dirty!
Hands-On: Running WordPress and MariaDB with Docker Compose
We'll run WordPress with a MariaDB database, a classic multi-container example. If you've read How to Create a Blog with WordPress, this time we'll run it ourselves on a server, without a hosting panel.
Step #1: Create a Project Folder
Each Compose project should have its own folder. By default, the folder name also becomes the project name. Create a new folder and move into it:
mkdir -p ~/wordpress-compose
cd ~/wordpress-composeStep #2: Store Passwords in a .env File
Don't write passwords directly in compose.yaml, especially if the file will live in Git. Compose automatically reads a file named .env in the project folder and uses it to fill in ${...} variables in the Compose file.
Create the .env file with the nano editor:
nano .envFill it with the following configuration, then replace the password with a strong random one:
DB_NAME=wordpress
DB_USER=wpuser
DB_PASSWORD=change-to-a-strong-passwordSave with Ctrl+O, press Enter, then exit with Ctrl+X. After that, restrict the file so only your user can read it:
chmod 600 .envImportant: if you keep this project folder in Git, add
.envto your.gitignoreso the password never gets committed.
Step #3: Write the compose.yaml File
Now create the Compose file:
nano compose.yamlPaste the following configuration:
services:
db:
image: mariadb:lts
restart: unless-stopped
environment:
MARIADB_DATABASE: ${DB_NAME}
MARIADB_USER: ${DB_USER}
MARIADB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is not set in .env}
MARIADB_RANDOM_ROOT_PASSWORD: "1"
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:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: ${DB_NAME}
WORDPRESS_DB_USER: ${DB_USER}
WORDPRESS_DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is not set in .env}
volumes:
- wp_data:/var/www/html
volumes:
db_data:
wp_data:A few interesting things in this file:
WORDPRESS_DB_HOST: db: WordPress reaches the database simply by its service name,db. Compose takes care of the IP address through the internal network.${DB_PASSWORD:?...}: the value comes from.env. The:?syntax makes Compose stop immediately with an error if the variable is empty, so you won't accidentally run a database without a password.MARIADB_RANDOM_ROOT_PASSWORD: the MariaDB root password is randomly generated, since WordPress only needs thewpuseraccount.healthcheckandcondition: service_healthy: WordPress only starts once MariaDB is actually ready to accept connections. Thehealthcheck.shscript ships with the official MariaDB image, as described in the MariaDB documentation.db_dataandwp_data: named volumes that persist the database and WordPress files (themes, plugins, uploads).
Before starting anything, it's worth checking that the file is valid. The following command prints the final configuration after variables from .env have been filled in:
docker compose configIf there's an indentation mistake or an empty variable, this command will show the error right away.
Note: the output of
docker compose configincludes the passwords from.env. Don't paste it into a forum or group chat when asking for help.
Step #4: Start All the Containers
This is the fun part. Start the whole application in the background with a single command:
docker compose up -dCompose will pull the images, create the network and volumes, start MariaDB, wait until it's healthy, then start WordPress. The output looks roughly like this:
[+] Running 5/5
✔ Network wordpress-compose_default Created
✔ Volume wordpress-compose_db_data Created
✔ Volume wordpress-compose_wp_data Created
✔ Container wordpress-compose-db-1 Healthy
✔ Container wordpress-compose-wordpress-1 StartedNotice that the network, volume, and container names all start with wordpress-compose, our project folder name. That's how Compose keeps one project separate from another on the same server.
Step #5: Check the Status and Open WordPress
Check the container status with the following command:
docker compose psIf the STATUS column shows Up for both services, and db is marked (healthy), everything is running. Now open your browser and go to http://YOUR-SERVER-IP:8080. The WordPress installation page will appear. Done!
Commonly Used Docker Compose Commands
Run all of the commands below from inside the project folder (the folder containing compose.yaml).
| Command | Purpose |
|---|---|
docker compose up -d |
Create and start all services in the background |
docker compose ps |
Show the status of the project's containers |
docker compose logs -f wordpress |
Follow a service's logs in real time (Ctrl+C to exit) |
docker compose exec db bash |
Open a shell inside a running container |
docker compose restart wordpress |
Restart a single service |
docker compose stop |
Stop containers without removing them |
docker compose down |
Stop and remove containers and networks |
docker compose pull |
Pull the latest version of the images in use |
docker compose config |
Validate and print the final configuration |
docker compose ls |
List all running Compose projects |
How to Update Your Application
One of Compose's biggest advantages is how short the update process is. To update WordPress and MariaDB to their latest images, run these two commands:
docker compose pull
docker compose up -dCompose only recreates containers whose image has changed. Your data stays safe because it lives in volumes. Still, make a habit of backing up your data before major updates.
Be Careful with down -v
The docker compose down command removes containers and networks, but it does not remove named volumes. Your data stays safe.
It's a different story if you add the -v option:
docker compose down -vAccording to the docker compose down documentation, this option also removes the named volumes declared in the Compose file. That means your database and all your WordPress files will be permanently gone. Only use it when you really want to start from scratch.
Docker Compose and the UFW Firewall on a VPS
Now, this is the part that's often overlooked and can be dangerous. If you've enabled the UFW firewall on your VPS (for example by following How to Secure a New Linux VPS), you might assume port 8080 can't be reached from outside because you haven't allowed it in UFW.
In reality, ports published by Docker can bypass UFW rules. According to Docker's firewall documentation, Docker routes container traffic through the iptables nat table, which is processed before UFW's filter rules. As a result, UFW rules for those ports are effectively ignored.
Warning: don't treat
sudo ufw statusas proof that your container ports are closed. A port like8080:80incompose.yamlwill be open to the entire internet, even if UFW doesn't allow it.
The simplest fix is to publish the port to localhost only. Change the ports section of the wordpress service to:
ports:
- "127.0.0.1:8080:80"That way, as described in the port publishing documentation, port 8080 can only be reached from the server itself. To expose it publicly, put a reverse proxy (such as Nginx, Caddy, or Traefik) in front of it to handle the domain and HTTPS, and only open ports 80 and 443 in UFW.
Apply the change by running:
docker compose up -dIf you only want to reach WordPress from your own computer for now, you can also use an SSH tunnel from your local machine:
ssh -L 8080:127.0.0.1:8080 user@YOUR-SERVER-IPAs long as that SSH session stays open, open http://localhost:8080 in your computer's browser.
One more note: a service without a ports section, like db in our example, isn't exposed to the outside at all. The database can only be reached by other containers on the same project network. That's exactly why you should not publish your database port unless you really need to.
Tips for Using Docker Compose
Tip #1: Pin Image Versions in Production
The latest tag is handy for learning, but it can bring major changes without you noticing when you run docker compose pull. For production servers, use a more specific version, such as wordpress:6-apache, and bump it deliberately after reading the release notes.
Tip #2: Always Set a restart Policy
The restart: unless-stopped option makes containers come back up automatically after a server reboot or a crash, unless you stopped them yourself. Without it, your app will stay down after the server restarts.
Tip #3: One Folder per Project
Keep each application in its own folder, for example ~/apps/wordpress, ~/apps/uptime-kuma, and so on. Besides being tidy, this keeps network and volume names from clashing and makes per-app backups easier.
Tip #4: Need a GUI? Try CapRover
If you'd rather manage apps from a web dashboard with automatic HTTPS, a platform like CapRover is a good alternative. The guide is in How to Install CapRover on a VPS.
Conclusion
From this article, we can conclude that Docker Compose is the most practical way to run multi-container applications on a single server. You write the whole configuration once in compose.yaml, then start, update, and move it with a few short commands.
Things to Remember
- Use the
docker composecommand (with a space), not the olddocker-compose. - The
version:line in Compose files is obsolete and no longer needed. - Keep passwords in a
.envfile, not directly incompose.yaml. - Use
healthchecktogether withdepends_onandcondition: service_healthyto get the startup order right. docker compose down -vpermanently deletes the data in your volumes.- On a VPS, Docker ports can bypass UFW. Publish to
127.0.0.1and use a reverse proxy.
Next Steps
Once WordPress is running, the logical next step is to put an HTTPS reverse proxy in front of it and set up regular backups for the db_data and wp_data volumes. After that, try running other self-hosted apps using the same pattern.
Got a question or stuck somewhere? Feel free to ask. Stay tuned to Teknotut!
Resources and Useful Links
- Install Docker Engine on Ubuntu (official docs)
- Docker Compose documentation
- Compose file reference
- Docker Compose releases on GitHub
- Official WordPress image on Docker Hub
I hope this article helps.



