Docker and Compose on Debian
Install Docker Engine from the official Docker apt repository on Debian, understand containers and Compose, and run your first real self-hosted service from a docker-compose.yml.
In this series
Build a homelab on Debianlevel:Intermediate verified:Jun 2026
ℹ️ Hands-on — you'll create files and run commands on your own Debian box. Set that up first.
You have a hardened Debian box sitting on your network, and now you want it to actually do something — run a dashboard, a wiki, a media server, a password manager. You could install each of those the old way: chase down dependencies, drop files all over the filesystem, fight version conflicts, and pray nothing collides. Or you can run every service in a container — a self-contained, throwaway box that ships with everything it needs and touches nothing else. That is what this post sets up. We install Docker Engine from the source that actually ships current versions, learn just enough about what a container is to not be dangerous, and finish by bringing up a real service with Compose.
What a container is
A container is a running process that has been given its own private view of the system: its own filesystem, its own network interface, its own process tree. From the inside it looks like a tiny independent machine. From the outside it is just a normal Linux process, isolated by two kernel features — namespaces (which control what the process can see) and cgroups (which control how much it can use). There is no second operating system booting inside it, which is why a container starts in milliseconds and a virtual machine takes a minute.
A container is created from an image: a read-only, layered template containing a filesystem and the metadata describing how to start the process. The image is the recipe; the container is the dish. You can run ten containers from one image, and each gets its own writable layer on top while sharing the read-only base underneath. (If you have ever wondered why it is called an "image" at all, the maritime metaphor runs deep — see where the name Docker comes from.)
flowchart LR
R["Dockerfile<br/><i>build recipe</i>"] -->|"docker build"| I["image<br/><i>read-only template</i>"]
I -->|"docker run"| C1["container 1<br/><i>running process</i>"]
I -->|"docker run"| C2["container 2"]
I -->|"docker run"| C3["container 3"]
One image, many containers — each gets its own writable layer over the shared read-only base.
Docker Engine is the daemon that builds images, pulls them from registries like Docker Hub, and runs them as containers. The docker command-line tool talks to that daemon over a local socket. Install one, get both.
Installing Docker Engine the right way
Debian ships a package called docker.io in its own repositories. Do not use it. It is maintained by Debian rather than Docker, it lags behind upstream by months, and it does not include the modern Compose plugin. The supported path — the one Docker itself documents — is to add Docker's own apt repository so you get current docker-ce releases and security updates directly from the source.
First, set up the keyring. This installs the prerequisites, then downloads Docker's signing key into a dedicated keyrings directory so apt can verify the packages are genuinely from Docker:
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Next, add the repository itself. This writes a docker.sources file that points apt at Docker's Debian repo, automatically filling in your Debian release codename (bookworm, trixie, etc.) and CPU architecture so you do not hard-code anything:
sudo 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
sudo apt update
ℹ️ Note The newer.sources(deb822) format replaces the old single-linedocker.listyou may see in older tutorials. Both work, but this is what Docker's docs use today. If you ever see a/etc/apt/sources.list.d/docker.listlying around from a previous attempt, delete it so you do not configure the repo twice.
Now install the engine, the CLI, the containerd runtime, and the two plugins you actually want — Buildx and Compose v2:
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
That docker-compose-plugin is the important one. It gives you docker compose (a subcommand, two words), the modern v2 implementation written in Go — not the old standalone docker-compose Python script (one word, hyphen) that is now end-of-life. Throughout this post, when you see docker compose, that is the plugin you just installed.
Confirm the daemon is alive and that it can pull and run an image:
sudo systemctl status docker
sudo docker run hello-world
hello-world is a tiny official image that prints a confirmation message and exits. If you see "Hello from Docker!", the engine pulled an image from Docker Hub, created a container, ran it, and tore it down — the entire pipeline in one command. Make sure Docker comes back after a reboot:
sudo systemctl enable docker.service
sudo systemctl enable containerd.service
Running Docker without sudo
Typing sudo before every docker command gets old fast. Docker lets you add your user to the docker group so the CLI can reach the daemon's socket directly. Create the group (it usually already exists after install) and add yourself:
sudo groupadd docker
sudo usermod -aG docker $USER
newgrp docker
The newgrp docker line applies the new group to your current shell; for it to take effect everywhere you should log out and back in. After that, docker run hello-world works with no sudo.
⚠️ Warning Membership in the docker group is root-equivalent. The Docker daemon runs as root, and anyone who can talk to its socket can mount any path on the host into a container and read or write it as root — there is no privilege boundary. Only add trusted, individual login users to this group. Never add a shared service account, and never treat "in the docker group" as a lesser permission than "has sudo." They are the same thing, per Docker's own post-install docs.What Compose is
Running a single container by hand with a long docker run line is fine for hello-world. Real services are messier: they need persistent storage, environment variables, a fixed restart policy, a published port, often a database container alongside them. Remembering and retyping that every time is a recipe for drift and mistakes.
Docker Compose solves this by letting you declare the whole setup in a single YAML file, docker-compose.yml (or compose.yaml). You describe the desired state — these services, these images, these volumes, these ports — and Compose reconciles reality to match it. One command brings everything up; one command tears it all down.
flowchart TD
F["compose.yaml<br/><i>declared desired state</i>"] -->|"docker compose up -d"| P["Compose reads the file"]
P --> N["creates a network<br/>for the project"]
P --> V["creates named<br/>volumes"]
P --> C["starts each service<br/>as a container"]
C --> S["service running<br/><i>detached, in background</i>"]
docker compose up -d turns one declarative file into a running, networked set of containers.
💡 Tip Compose is how you will define every service in your homelab. Get comfortable with onedocker-compose.ymlper service, each in its own directory, committed to a Git repo. That directory is your documentation and your backup — destroy a container, re-rundocker compose up -d, and you are back exactly where you were. Treat the YAML as the source of truth, never the running container.
Your first real service with Compose
Let us run something genuinely useful: Dozzle, a lightweight web log viewer that shows the live output of all your other containers in a browser. It needs no database and stores no state, which makes it a clean first target.
Create a directory for the service:
mkdir -p ~/services/dozzle
cd ~/services/dozzle
Each service gets its own folder, holding one Compose file:
~/services/dozzle/
└── docker-compose.yml
Save this as docker-compose.yml in that folder:
services:
dozzle:
image: amir20/dozzle:latest
container_name: dozzle
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
Read that top to bottom. image is what to run. restart: unless-stopped tells Docker to bring the container back after a crash or a reboot, but to leave it down if you deliberately stopped it. ports: "8080:8080" maps port 8080 on the host to 8080 inside the container — host on the left, container on the right. The volumes line mounts the Docker socket read-only (:ro) so Dozzle can read logs but cannot control the daemon.
From inside the folder, bring it up:
docker compose up -d
The -d runs it detached, in the background. Compose pulls the image, creates a project network, and starts the container. Check it:
docker compose ps
You should see the container running with its port mapping:
NAME IMAGE STATUS PORTS
dozzle amir20/dozzle:latest Up 10 seconds 0.0.0.0:8080->8080/tcp
And docker compose logs will show Dozzle's startup output. Now open http://<your-server-ip>:8080 in a browser and you will see a live log dashboard. To stop and remove everything Compose created for this project:
docker compose down
💡 Tipdocker compose up -dis also how you update a service. Edit the image tag (or rundocker compose pullto fetch newer versions), thendocker compose up -dagain — Compose recreates only the containers whose definition changed and leaves the rest untouched. Adddocker image pruneoccasionally to clean out the old layers that updates leave behind.
⚠️ Warning That port 8080 is currently exposed on every interface of your server. On a trusted home LAN that is acceptable for now, but Dozzle has no authentication — anyone who can reach the port can read your logs. Do not forward this port through your router to the internet. The next post puts a reverse proxy in front of services so the only thing exposed is one hardened entry point with TLS.ℹ️ Note A common first stumble:docker composeprints "permission denied" on/var/run/docker.sock. That means your shell has not picked up thedockergroup yet. Log out and back in, or runnewgrp docker, and try again.
A short close
You now have the same container runtime that production teams run, installed from the source that keeps it current, and you have seen the full loop: an image becomes a container, and a compose.yaml becomes a managed service you can stand up or tear down with a single command. From here, every new piece of your homelab follows the same shape — a directory, a Compose file, docker compose up -d.
The one loose end is exposure. Right now each service lives on its own raw port with no encryption and no auth. The next post in the series puts a reverse proxy out front so you reach everything over HTTPS at clean hostnames instead of :8080 URLs. If you want to back up to the bigger picture first, the homelab series hub lays out where this all leads — and if the maritime naming has you curious, where the name Docker comes from is a fun detour.