Managing Docker Compose Applications with systemd - A Portainer Example
While setting up my own private container registry with Harbor (more on that in a future post), I ran into the usual problem: how do you manage Docker Compose applications properly on a production server? Sure, you can SSH in and run docker-compose up -d, but that's not how you run services in production.
The answer is systemd. If you're running Linux, you're probably already using it for everything else. Why not your Docker apps?
The Problem
Most Docker tutorials stop at "run docker-compose up and you're done!" That's fine for development, but in production, you need:
- Automatic startup on boot
- Proper dependency management (don't start the app before Docker is ready)
- Integration with standard service management tools
- Centralised logging through journald
Running containers manually with docker-compose is messy. You end up with screen sessions, or worse, containers that don't restart after a reboot.
A Simple Example: Portainer
Let's use Portainer as an example. It's a single-container Docker management UI, simple enough to understand but real enough to be useful.
Here's a basic docker-compose.yml:
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: always
ports:
- "9000:9000"
- "9443:9443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
volumes:
portainer_data:
Now, instead of manually running docker-compose up -d, create a systemd service at /etc/systemd/system/portainer.service:
[Unit]
Description=Portainer Docker Management UI
Documentation=https://docs.portainer.io
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/portainer
ExecStart=/usr/local/bin/docker-compose up -d
ExecStop=/usr/local/bin/docker-compose down
Restart=on-failure
RestartSec=30s
TimeoutStartSec=120
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Enable and start it:
systemctl daemon-reload
systemctl enable portainer.service
systemctl start portainer.service
That's it. Now you can manage it like any other service:
systemctl status portainer
systemctl restart portainer
journalctl -u portainer -f
Breaking Down the Service File
A few things worth explaining:
Type=oneshot with RemainAfterExit=yes: This is critical. Docker Compose starts containers and exits. Without RemainAfterExit=yes, systemd would think the service failed because the process ended. This combination tells systemd "the service is up as long as the start command succeeded."
After and Requires: These ensure Docker is fully running before we try to start containers. After controls ordering, Requires creates a dependency. If Docker stops, this service stops too.
Restart=on-failure: If docker-compose fails (returns non-zero), systemd will retry after 30 seconds. This handles transient failures.
StandardOutput/StandardError=journal: Sends all output to journald, so you can use journalctl instead of hunting for log files.
The Gotchas
This approach works well, but there are some things to watch out for:
- The
restart: alwaysin docker-compose.yml still matters. The systemd service manages docker-compose, not the individual containers. If a container crashes, Docker's own restart policy handles it. If docker-compose itself fails, systemd handles it. - ExecStop runs
docker-compose down. This removes the containers. If you just want to stop them, usedocker-compose stopinstead. I preferdownbecause it's cleaner, but it means restarts take slightly longer. - WorkingDirectory must contain docker-compose.yml. Docker Compose looks in the current directory. If you don't set this, it'll fail.
- Path to docker-compose might differ. Use
which docker-composeto find it. On some systems, it's/usr/bin/docker-compose, others/usr/local/bin/docker-compose.
Why Not Just Use Docker's Restart Policy?
You could rely entirely on restart: always in the compose file. Docker will restart containers, even after a reboot (once Docker itself starts). So why bother with systemd?
- Visibility:
systemctl statusshows you the state at a glance - Control: Standard tooling across all services
- Dependencies: You can chain services (mount storage before starting the app)
- Logging: Centralised in journald with all your other services
For simple deployments, Docker's restart policy is probably enough. But once you have multiple services with dependencies, systemd becomes invaluable.
Real-World Complexity
The Harbor setup I'm working on is more complex. It needs an NFS mount to be ready before Harbor starts, and Harbor itself manages about 10 different containers. The systemd service handles the dependency chain: network → NFS mount → Docker → Harbor.
That's a longer story for another post, but the principle is the same. Systemd gives you proper service management instead of hoping everything comes up in the right order after a reboot.
Final Thoughts
This isn't groundbreaking stuff. Systemd has been around forever, and people have been wrapping Docker Compose in service files for years. But it's surprising how many tutorials skip this step entirely.
If you're running anything beyond a dev environment, take 10 minutes to write a proper service file. Your future self will thank you when the server reboots and everything just works.
Coming up next: I'm documenting my entire Harbor setup - a production-grade private container registry running on Photon OS, with NFS-backed storage served from an Unraid array, complete SSL setup via Nginx Proxy Manager, and vulnerability scanning with Trivy. It's been an interesting journey working through NFS mount dependencies, systemd service chaining, and troubleshooting some nasty 401 errors caused by disk spin-down behaviour. Watch this space.
Member discussion