Docker Compose is the point where Docker usually stops feeling like a pile of one-off commands and starts feeling manageable. If you want to self-host more than one app, or even just keep one app organized properly, Compose gives you a repeatable way to describe the stack and bring it back exactly the same way later.
In this guide, you will learn what Docker Compose actually does, how to organize a self-hosted app folder, how to write a clean compose.yaml, how to start and inspect services, and how to update or remove a stack without panicking and clicking random things in your terminal.
What Docker Compose is in plain English
Docker Compose lets you define one or more containers in a YAML file and manage them as a group.
Instead of remembering a long docker run command every time, you keep the important settings in a file:
- image name
- ports
- volumes
- environment variables
- restart policy
- networks
Then you use commands like docker compose up -d or docker compose ps to manage the whole stack.
That matters for self-hosting because most real apps are not just one disposable container. They usually need persistent storage, a few settings, and a sane way to stop, start, and update them later.
Docker Compose vs the old docker-compose command
If you have been reading older tutorials, you may still see docker-compose with a hyphen.
For new installs, Docker's current docs use the Compose plugin form:
docker compose
That is the version this guide uses.
If docker compose version works on your server, you are on the right path.
Why Compose is such a big deal for self-hosting
Compose helps beginners and experienced admins for the same basic reason: it moves container settings out of your memory and into a file.
That gives you a few practical wins:
- you can rebuild the stack later without guessing the original command
- you can see ports, volumes, and variables in one place
- you can back up the app folder with the configuration alongside it
- you can stop and start the whole stack with predictable commands
- you are less likely to create mystery containers you forget about three weeks later
This is one of the reasons so many self-hosted app projects publish a Compose example first.
What you need before you start
Before following along, make sure you already have:
- a Linux server or VPS
- Docker installed and working
- a user account that can run Docker commands
- basic SSH access to the machine
If Docker is not installed yet, start here first:
What a good self-hosted app folder looks like
A simple folder layout makes future updates and troubleshooting much less annoying.
For one app, a clean starting point looks like this:
/opt/uptime-kuma/
├── compose.yaml
├── .env
└── data/
That pattern works well because:
compose.yamldefines the stack.envstores reusable variablesdata/holds persistent app files when you use bind mounts
Some apps use named Docker volumes instead of a local data/ folder.
That is fine too.
The important part is that you know where your real data lives before you trust the app with anything important.
Step 1: Confirm the Compose plugin is available
First, make sure the modern Docker Compose plugin works on your machine.
docker compose version
If Docker returns a version string, you are ready to continue.
If it says the command does not exist, go back to your Docker install and make sure the Compose plugin was installed.
Step 2: Create a folder for a sample app
To keep things practical, this guide uses a very small example stack based on the nginxdemos/hello container.
It is lightweight, safe for a first Compose walkthrough, and easy to test in a browser.
Create the app folder.
sudo mkdir -p /opt/compose-demo
Now move into it.
cd /opt/compose-demo
If you plan to manage app folders as a non-root user, adjust ownership after creating the directory.
sudo chown "$USER":"$USER" /opt/compose-demo
Step 3: Create a .env file for reusable values
A .env file helps you avoid hardcoding everything directly in compose.yaml.
It also makes simple changes less annoying later.
Create the file.
cat > .env <<'EOF'
APP_PORT=8080
TZ=Etc/UTC
EOF
This example is intentionally small. Real app stacks often store things like:
- published ports
- timezone
- image tags
- usernames
- generated secrets
- domain names
Important: A plain
.envfile is convenient, but it is not magical secret protection. Treat passwords and API keys carefully, especially if you sync app folders into Git or backups without thinking about it first.
Step 4: Create the compose.yaml file
Now create the actual Compose file.
cat > compose.yaml <<'EOF'
services:
hello:
image: nginxdemos/hello:plain-text
container_name: compose-demo
restart: unless-stopped
ports:
- "${APP_PORT}:80"
environment:
TZ: ${TZ}
EOF
This stack is small, but it already shows the most important Compose habits.
What each part does
services:
This is where you define the containers in the stack.
Here I only have one service called hello.
image:
This tells Docker which image to pull and run. In this case, it uses a simple demo web server.
container_name:
This gives the running container an easy-to-recognize name.
It is optional, but it helps beginners when reading logs or docker ps output.
restart: unless-stopped
This is a very common self-hosting setting. It tells Docker to restart the container automatically unless you intentionally stopped it.
ports:
This publishes container port 80 on the host port stored in .env.
Because I set APP_PORT=8080, the site will be reachable on port 8080.
environment:
This passes environment variables into the container. Many real apps use this section for database settings, admin credentials, domain names, email config, or timezone values.
Step 5: Validate the Compose file before starting
Before you launch the stack, ask Docker to render the final config.
docker compose config
This is one of the most useful Compose commands for beginners. It helps you catch:
- YAML formatting mistakes
- missing variables
- invalid interpolation from
.env
If this command prints a clean combined config, your file is probably in good shape.
Step 6: Start the stack
Now bring the stack up in detached mode.
docker compose up -d
Docker will pull the image if needed, create the container, create the default network for the project, and start the service.
Detached mode means the container keeps running in the background after the command finishes. That is what you usually want for self-hosted apps.
Step 7: Check whether the service is running
List the services in the current stack.
docker compose ps
You should see the hello service running and port 8080 mapped.
For an app that exposes a web interface, this is one of the quickest sanity checks after startup.
Step 8: Test the app in a browser or with curl
If you are on the server itself, test the app with curl.
curl http://localhost:8080
If you are testing from your own computer and the VPS firewall allows it, open:
http://YOUR_SERVER_IP:8080
You should get a simple response from the demo container. If that works, your Compose stack is alive and reachable.
Step 9: Read logs when something looks wrong
If a container starts and then exits, logs are usually the first place to look.
docker compose logs
If you only want one service, specify it directly.
docker compose logs hello
For chatty apps, add -f to follow logs live.
docker compose logs -f hello
This is much more useful than guessing. A lot of beginner container problems are actually obvious once you read the logs for thirty seconds.
Step 10: Stop and start the stack cleanly
To stop the services without deleting them, use:
docker compose stop
To start them again later, use:
docker compose start
This is useful when you want a temporary pause without rebuilding anything.
Step 11: Remove the stack when you are done
To remove the containers and the default network created by the project, run:
docker compose down
That removes the running stack, but it does not automatically delete named volumes or bind-mounted app data.
That is a good thing.
It means down is usually safer than beginners expect.
The most important Compose habits for real self-hosted apps
Once you move past toy examples, these are the habits worth keeping.
Keep one app per folder
Do not throw unrelated Compose files into one giant random directory. A dedicated folder per app makes backups, restores, and troubleshooting much easier.
Use .env for the settings that actually change
Good candidates include:
- published ports
- hostnames
- image tags
- timezone
- secret values you generate once
Not every stack needs .env, but it becomes useful quickly when you want cleaner files or lighter edits.
Know whether your app uses bind mounts or named volumes
This matters for backups.
A bind mount might look like this:
volumes:
- ./data:/config
A named volume might look like this:
volumes:
- app-data:/config
Both can be valid. The important question is: if the VPS died tonight, would you know what to restore?
Do not edit production stacks carelessly
A small YAML typo can break a stack on restart. When you change a real app:
- review the compose file carefully
- run
docker compose config - take a backup if the app matters
- then apply the change
That order is much less exciting than breaking your database, which is exactly why it is the better order.
How to update a typical Compose-based app
For many self-hosted apps, the routine update flow looks like this.
Pull newer images.
docker compose pull
Recreate the containers with the new image.
docker compose up -d
Confirm the services are healthy.
docker compose ps
If you want to clean up unused old image layers afterward, you can do that separately.
docker image prune
Read the app's official release notes before updating anything important. Some apps need extra upgrade steps, schema migrations, or version-specific warnings.
Common problems and quick fixes
docker compose says it cannot find a configuration file
You are probably in the wrong directory.
Check where you are.
pwd
Then make sure compose.yaml or docker-compose.yml exists in that folder.
find . -maxdepth 1 \( -name 'compose.yaml' -o -name 'docker-compose.yml' \)
A port is already in use
That usually means another service is already listening on the host port you picked.
Change the host-side port in .env, then apply the stack again.
For example, switch from 8080 to 8081 if something else already owns 8080.
Variables are not being substituted the way you expected
Run this first:
docker compose config
If the rendered output is wrong, check your .env file for typos, missing values, or quotation mistakes.
docker compose down removed the app but not the data
That is normal for persistent data. Compose removes the containers and network, but it does not automatically wipe named volumes or bind-mounted folders unless you explicitly tell it to.
This is usually a safety feature, not a bug.
Where to go next
Once you are comfortable with Compose, the next useful tutorials are:
- How to Install Docker on Ubuntu and Run Your First Container
- a reverse proxy tutorial such as Nginx Proxy Manager or Caddy
- a real app deployment such as Immich, Uptime Kuma, or Vaultwarden
Docker's official docs are also worth bookmarking:
- Docker Compose overview
- Install the Docker Compose plugin
docker compose upreferencedocker compose psreference
Final thoughts
Docker Compose is not flashy, but it is one of the most useful skills you can learn early in self-hosting. Once you understand how a good Compose file is structured, later tutorials make a lot more sense because you are reading a configuration pattern instead of memorizing disconnected shell commands.
That is the real win: fewer mystery commands, cleaner app folders, and a much better chance that future-you will still understand what present-you built.
