Search "Docker Compose vs Portainer" and you'll find people treating it like a fork in the road — pick one. It's the wrong frame. Compose is a way to define your stacks in a file; Portainer is a web UI for managing what's running. They sit at different layers, and the homelab setups that age best usually use both, with Compose as the source of truth.
What each one actually is
Docker Compose is the declarative half. You write a compose.yaml, commit it to git, and docker compose up -d brings the stack to exactly that state. The file is the record — rebuilding a host becomes a git clone and one command.
Portainer is the visibility and operations layer: a popular web UI for managing Docker environments — containers, images, volumes, networks, logs, and "Stacks," which are just Compose files it deploys for you. It's genuinely handy for seeing what's running, tailing logs, and restarting something without SSHing in.
| Reach for Compose when | Reach for Portainer when |
|---|---|
| Defining a stack you'll keep | Eyeballing what's running |
| Version-controlling your setup | Tailing logs, quick restarts |
| Rebuilding a host from scratch | Giving someone safer visibility |
Two things that have changed
If you're copying configs from older tutorials, two updates matter. First, it's docker compose (a plugin), not the old hyphenated docker-compose binary — move your scripts and muscle memory over. Second, the top-level version: key is obsolete; Compose v2 ignores it and warns about it. Just delete the line:
# compose.yaml -- modern Compose v2, no version key
services:
portainer:
image: portainer/portainer-ce:lts # or pin the current fixed LTS at deploy time
restart: unless-stopped
ports:
- "9443:9443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
volumes:
portainer_data:
That Docker socket is the powerful part. Portainer needs it to manage the local engine, so treat this container as infrastructure-level access, not a harmless mount: keep it off the public internet, put it behind strong authentication, keep it updated, back up the portainer_data volume, and only give admin rights to people who should be able to control the Docker host. A read-only mount doesn't make it safe — socket access is socket access.
The mistake to avoid
The trap isn't choosing Portainer — it's letting Portainer's web editor become the only copy of your stacks. Paste YAML into the UI to define a stack and that definition now lives only in Portainer's database. Lose that volume and you've lost your setup, with no git history to fall back on.
Portainer also bundles its own Compose version, which can lag behind the latest features — so a file that runs fine at the CLI might reject a newer key inside Portainer. Keep your compose.yaml files in a repo as the source of truth. If you like deploying through Portainer, use its Git-based stacks so the repo stays canonical and the UI is just the trigger — but point it only at repositories you control, and keep Portainer current. Git-backed stack handling has had real security fixes, so a stale Portainer is not where to cut corners.
How to run both
- Write each stack as a
compose.yamlin a git repo — one folder per stack. - Deploy from the CLI with
docker compose up -d, or via a Portainer Git-based stack pointed at that repo. - Use Portainer for visibility: logs, stats, quick restarts, and a read-only view you can hand to someone else.
- Change the file, commit, redeploy — never hand-edit a running container and call it done.
If Portainer rejects a Compose file the CLI accepts
It's usually the bundled-Compose gap: a key the CLI accepts that Portainer's older Compose doesn't. Deploy that stack from the CLI, or update Portainer — and either way, keep the file in git so the two never drift far.
Compose is the blueprint; Portainer is the dashboard. Treat the files as canonical and Portainer as the window onto them, and you get the GUI convenience without trading away a setup you can rebuild from scratch.