On this page
Almost every self-hosted app you’ll ever install — Immich, Homepage, Uptime Kuma, the entire media stack — is deployed the same way: with a short text file and one command. That file format is Docker Compose, and once it clicks, you can deploy virtually anything. This guide is the click.
We’ll skip the theory-heavy tour and just build something, explaining each new idea the moment it appears. By the end you’ll have a running service and — more importantly — you’ll understand the four concepts (images, containers, ports, volumes) that every future install reuses.
A host with Docker installed. In a homelab that’s usually a Docker LXC or a VM on Proxmox. Everything below runs from that host’s command line. Confirm Docker is ready with docker –version and docker compose version.
The four ideas, in one picture
- Image — the read-only template for an app, downloaded from a registry (Docker Hub). Think recipe.
- Container — a live, running copy made from an image. Think the finished dish. Disposable: delete it and start another anytime.
- Port — a container is sealed off by default; publishing a port pokes one hole so your browser can reach the app.
- Volume — storage that lives outside the container so your data survives when the container is replaced.
Hold those four in mind. The compose file is just a tidy way to declare them.
Build your first stack
Each stack gets its own folder. The folder name becomes the project name, and the compose file lives inside it.
mkdir -p ~/stacks/hello && cd ~/stacks/hello
Create a file named exactly docker-compose.yml. We’ll run nginx, a tiny, rock-solid web server, and serve a page from a folder on the host. Every line is labelled.
services: # everything we want to run
web: # a name we pick for this service
image: nginx:latest # the template to download and run
container_name: hello-web
ports:
- "8080:80" # host port 8080 -> container port 80
volumes:
- ./site:/usr/share/nginx/html:ro # serve our folder, read-only
restart: unless-stopped # start on boot, restart if it crashes
Ports read host:container. “8080:80” means “browse to port 8080 on my machine, and Docker forwards it to port 80 inside the container.” Volumes read host:container too. ./site:/usr/share/nginx/html means “the site folder next to this file appears inside the container at nginx’s web root.” Change a file in ./site and the running site changes — the data lives on the host, not trapped in the container.
Create the folder the volume points at, and drop in an index page.
mkdir -p site
echo "<h1>It works. I run my own web server now.</h1>" > site/index.html
One command builds and starts the whole stack in the background. The -d means “detached” — it runs without holding your terminal.
docker compose up -d
Open http://YOUR-HOST-IP:8080 in a browser (replace YOUR-HOST-IP with the address of your Docker host — the machine you’ve been typing on). There’s your page — served by a container you defined in eight lines. You just deployed a service, and every future deploy will feel like this one.
The five commands you’ll use forever
These work in any stack folder, and they’re 90% of day-to-day Docker:
docker compose up -d # start (or apply changes to) the stack
docker compose ps # what's running, and is it healthy?
docker compose logs -f # live logs — your first stop when something's wrong
docker compose pull # download newer images
docker compose down # stop and remove the containers (volumes are kept)
This is the pattern for updating almost everything you self-host: docker compose pull to fetch the new image, then docker compose up -d to recreate the container with it. Because your data is in a volume, it carries straight over. That’s the whole update routine for Immich, Homepage, Uptime Kuma — all of them.
Named volumes vs. bind mounts (the one nuance worth knowing)
You just used a bind mount — a specific host folder (./site) mapped in. That’s perfect when you want to see and edit the files. The other kind is a named volume, which Docker manages for you:
services:
db:
image: postgres:16
volumes:
- db-data:/var/lib/postgresql/data # named volume, managed by Docker
volumes:
db-data: # declared down here
Rule of thumb: use a bind mount for things you want to open and edit yourself (config files, a media library on your NAS share); use a named volume for opaque application data like databases, where you never touch the files directly. Both survive docker compose down. Both are how your data outlives the container.
You just unlocked the whole catalogue
That’s it — that’s the skill. Every self-hosted guide from here is a variation on the same four ideas and the same five commands. When you read an app’s install docs and see a docker-compose.yml, you now know exactly what each line does and how to run it.
Ready to use it for real? Point this skill at Immich for private photo backup, a Homepage dashboard, or Uptime Kuma monitoring — or head back to the full roadmap and keep building.
Related posts:
- Docker on Proxmox: LXC vs VM — where to run your Compose stacks
- Immich: Self-Hosted Photos on Proxmox — a real Compose stack to deploy next
- Homepage Dashboard — another one-file Compose deploy
- Uptime Kuma: Dead-Simple Homelab Monitoring — monitor the stacks you deploy
- Traefik + Let’s Encrypt Reverse Proxy — put your Compose services behind HTTPS
- Deploy the Arr Stack with Docker Compose — a larger multi-service Compose example
- The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime — what goes wrong when containers call each other
localhost - Real Screenshots, Zero Leaks: My Disposable Demo Lab — a small Compose stack built purely to be photographed
Sources: Docker Compose documentation, Docker documentation.
Comments
Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.