Run Docker Compose as a Linux Service with systemd
Docker Compose on boot, managed by systemd.
Docker Compose on a Linux server should start on boot, stop cleanly on shutdown, and survive reboots without manual intervention.
Docker Compose is not Kubernetes, and that is fine for the workloads this guide targets. For many real systems, a Compose project on a single Linux host is the right amount of infrastructure — simple, readable, easy to back up, and good enough for internal tools, side projects, self-hosted services, staging environments, small production apps, and developer infrastructure.

The missing piece is usually service management. Running this manually is not enough:
docker compose up -d
A single command starts the stack, but it does not document how the stack should start on boot, stop during shutdown, reload after changes, write logs, recover from failures, or get updated safely. That is where systemd helps.
This guide walks through running a Docker Compose project as a Linux service with systemd — unit files, boot ordering, updates, logs, and backups. The split of responsibility is deliberate: Docker runs containers, Compose defines the stack, and systemd starts and stops the project on the host. It is part of Developer Tools - a Guide to Development Workflows.
When Docker Compose as a Service Makes Sense
Running Compose under systemd makes sense when you have:
- A single Linux server
- A small self-hosted application
- A reverse proxy stack
- A monitoring stack
- A local development platform
- An internal tool
- A staging environment
- A simple production service with known limits
Examples:
- Nginx Proxy Manager
- Traefik
- Gitea
- Grafana and Prometheus
- PostgreSQL plus a small web app
- Uptime Kuma
- Home Assistant helper services
- Private registry
- Internal API plus worker plus Redis
Compose is a good fit when the operational model is still understandable by one person reading one directory.
When Docker Compose Is Not Enough
Use something else when you need:
- Multi-node scheduling
- Automatic rescheduling across hosts
- Cluster-level service discovery
- Horizontal autoscaling
- Rolling deployments across many machines
- Fine-grained workload identity
- Complex network policy
- Large multi-team platform operations
At that point, Kubernetes, Nomad, Swarm, or a managed platform may be a better fit.
My practical rule is to avoid using Kubernetes just to skip learning systemd, and to avoid using Compose when the workload clearly needs orchestration across multiple hosts.
The Basic Architecture
A clean setup separates project files, the systemd unit, and persistent data on the host. The Compose project lives under /opt/myapp/ with compose.yaml, .env, data/, backups/, and optional scripts such as scripts/update.sh. The systemd unit file sits at /etc/systemd/system/myapp.service.
Each layer has a clear job: Docker runs containers, Compose defines the application stack, systemd starts and stops the Compose project on boot and shutdown, the host filesystem stores persistent data, backups stay explicit, and updates go through scripted, reviewable steps. This layout is deliberately boring, because boring infrastructure is easier to repair when something breaks at 2 a.m.
Prepare the Compose Project Directory
Create a directory under /opt:
sudo mkdir -p /opt/myapp
sudo chown -R "$USER":"$USER" /opt/myapp
cd /opt/myapp
Create a Compose file:
nano compose.yaml
Example:
services:
web:
image: nginx:stable
restart: unless-stopped
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro
healthcheck:
test: ["CMD-SHELL", "nginx -t || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
volumes: {}
Create the content directory:
mkdir -p html
echo "Hello from Docker Compose" > html/index.html
Test manually first:
docker compose up -d
docker compose ps
docker compose logs --tail=50
Then stop it before handing lifecycle to systemd:
docker compose down
Do not create a systemd service until the Compose project works manually. While you test, keep the Docker Compose Cheatsheet nearby for ps, logs, pull, and project structure.
Use the Modern docker compose Command
Docker Engine and the Compose plugin must be installed before you write a unit file. On Ubuntu, Install Docker on Ubuntu walks through APT, Snap, rootless mode, and post-install security so you end up with a working docker compose command.
Use this:
docker compose version
Not this:
docker-compose version
The old docker-compose binary still exists on many machines, but modern Docker uses Compose as a Docker CLI plugin.
In service files and scripts, prefer:
/usr/bin/docker compose
You can find the Docker path with:
command -v docker
Usually it is:
/usr/bin/docker
Create a systemd Service for Docker Compose
If unit files are new to you, Run any Executable as a Service in Linux explains Type, ExecStart, systemctl, and the general systemd workflow. This section applies those patterns specifically to a Compose stack.
Create the service file:
sudo nano /etc/systemd/system/myapp.service
Use this unit:
[Unit]
Description=MyApp Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
Reload systemd:
sudo systemctl daemon-reload
Start the service:
sudo systemctl start myapp.service
Enable it on boot:
sudo systemctl enable myapp.service
Check status:
systemctl status myapp.service
Check containers:
cd /opt/myapp
docker compose ps
Why Type=oneshot and RemainAfterExit=yes?
This is the part many guides get subtly wrong.
docker compose up -d starts containers in detached mode and exits, so there is no long-running foreground Compose process for systemd to supervise. The systemd unit should not pretend that docker compose up -d is a long-running daemon.
Use:
Type=oneshot
RemainAfterExit=yes
This tells systemd:
- Run the start command.
- Consider the unit active after the command exits successfully.
- Run
ExecStopwhen the service is stopped.
That matches the actual behavior of detached Compose, which is why Type=oneshot with RemainAfterExit=yes is the right default for most stacks.
Why Not Type=simple?
With Type=simple, systemd expects the ExecStart process to keep running, but docker compose up -d exits after starting containers. That can make systemd think the service ended, then call stop logic or mark the unit inactive depending on configuration.
If you want Type=simple, you would usually run Compose in the foreground:
ExecStart=/usr/bin/docker compose up
That can work, but I usually do not prefer it for Compose stacks on servers. Detached containers plus explicit ExecStop are easier to operate.
A More Production-Friendly Unit
For a real server, I prefer a slightly stricter unit:
[Unit]
Description=MyApp Docker Compose stack
Documentation=https://example.com/docs/myapp
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
EnvironmentFile=-/opt/myapp/.env.systemd
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecReload=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
Important details:
WorkingDirectorypoints to the Compose project.ExecStartPrevalidates the Compose config.ExecReloadrecreates changed services.ExecStopstops and removes the Compose project containers and default network.EnvironmentFile=-...means the file is optional.
Create the optional systemd environment file:
nano /opt/myapp/.env.systemd
Example:
COMPOSE_PROJECT_NAME=myapp
Then reload systemd:
sudo systemctl daemon-reload
sudo systemctl restart myapp.service
Compose .env vs systemd EnvironmentFile
Compose and systemd each have their own environment mechanism, and mixing them up causes confusing “variable not set” failures at boot.
Compose automatically reads a .env file in the project directory for variable substitution in the Compose file.
Example .env:
APP_TAG=1.2.3
WEB_PORT=8080
Example compose.yaml:
services:
web:
image: nginx:${APP_TAG}
ports:
- "${WEB_PORT}:80"
A systemd EnvironmentFile sets environment variables for the docker compose command itself.
Example:
EnvironmentFile=-/opt/myapp/.env.systemd
For many projects, you only need Compose .env.
Use a systemd environment file when you want to define things such as:
COMPOSE_PROJECT_NAME=myapp
COMPOSE_FILE=compose.yaml
DOCKER_HOST=unix:///var/run/docker.sock
Do not use either file as a casual secrets vault. If secrets matter, use Docker secrets, an external secret manager, encrypted files, or at least strict permissions.
Set restrictive permissions:
chmod 600 /opt/myapp/.env
chmod 600 /opt/myapp/.env.systemd
Restart Policies: Docker vs systemd
There are two restart layers — container restart policy in Compose and systemd service restart policy — and they should not be mixed blindly.
For long-running containers, set restart policies in Compose:
services:
web:
image: nginx:stable
restart: unless-stopped
Common restart values:
| Policy | Meaning |
|---|---|
| no | Do not restart automatically |
| always | Restart after exit and daemon restart |
| on-failure | Restart only after failure |
| unless-stopped | Restart unless manually stopped |
For most persistent services, I prefer:
restart: unless-stopped
It is predictable and respects intentional manual stops.
The systemd unit itself should usually not restart repeatedly, because docker compose up -d is not the running workload. The containers are.
So avoid this unless you have a specific reason:
Restart=always
In most Compose-as-service units, let Docker handle container restarts.
Health Checks
Restart policies restart containers when processes exit. They do not magically fix every unhealthy application.
Add health checks where they are useful:
services:
app:
image: example/app:latest
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
Check health:
docker compose ps
Inspect a container:
docker inspect container-name
Health checks are especially useful for:
- Web apps
- Reverse proxies
- Databases
- Queues
- Internal APIs
- Workers with a health endpoint
They are less useful when they only check that a process exists, because a process that is alive but wedged still looks healthy. A bad health check is just another lie in YAML.
Startup Order and depends_on
Compose can define dependencies:
services:
app:
image: example/app:latest
depends_on:
db:
condition: service_healthy
db:
image: postgres:16
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
This can help startup ordering, but do not over-trust it. Applications should still handle retries — databases restart, networks flap, DNS takes time, and a resilient app retries connections instead of assuming perfect startup order.
Logs: journalctl and docker compose logs
Two log views cover most debugging: systemd captures the lifecycle of the unit itself, while Compose captures application output from running containers.
systemd service logs:
journalctl -u myapp.service -n 100 --no-pager
Follow systemd logs:
journalctl -u myapp.service -f
Compose service logs:
cd /opt/myapp
docker compose logs --tail=100
docker compose logs -f
docker compose logs -f web
For most app debugging, docker compose logs is more useful; for lifecycle debugging — start failures, unit crashes, permission errors — journalctl is more useful. If systemctl start myapp fails, check journalctl first. If the stack starts but the app is broken, check docker compose logs.
Log Rotation
Docker logs can grow forever if you do not configure them.
For small servers, configure Docker log rotation in /etc/docker/daemon.json:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
}
}
Restart Docker:
sudo systemctl restart docker
Then restart the Compose stack:
sudo systemctl restart myapp.service
This applies to newly created containers. Recreate containers if needed:
cd /opt/myapp
docker compose up -d --force-recreate
Log rotation is not glamorous, but it is one of the easiest ways to prevent a disk-full outage on a small server.
Updating a Compose Service
A simple manual update flow:
cd /opt/myapp
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
If managed by systemd, you can use:
sudo systemctl reload myapp.service
If your unit has:
ExecReload=/usr/bin/docker compose up -d --remove-orphans
But note: ExecReload does not pull images unless you include that step.
For explicit updates, create a script.
mkdir -p /opt/myapp/scripts
nano /opt/myapp/scripts/update.sh
Script:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/myapp
docker compose config --quiet
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
docker compose ps
Make it executable:
chmod +x /opt/myapp/scripts/update.sh
Run it:
/opt/myapp/scripts/update.sh
Then the service unit can remain focused on lifecycle, while the update script handles deployment.
Safer Update Script with Backup Hook
For stateful services, update only after backup.
#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/opt/myapp"
BACKUP_DIR="/opt/myapp/backups"
cd "$APP_DIR"
mkdir -p "$BACKUP_DIR"
echo "Validating compose file"
docker compose config --quiet
echo "Running backup hook"
if [ -x "$APP_DIR/scripts/backup.sh" ]; then
"$APP_DIR/scripts/backup.sh"
else
echo "No backup hook found"
fi
echo "Pulling images"
docker compose pull
echo "Recreating services"
docker compose up -d --remove-orphans
echo "Pruning unused images"
docker image prune -f
echo "Current status"
docker compose ps
This is still simple, but now it encodes an operational habit: backup before change.
Stopping the Service
Stop the stack:
sudo systemctl stop myapp.service
That runs:
docker compose down
By default, docker compose down removes:
- Containers for services in the Compose file
- Networks defined by the Compose file
- The default network
It does not remove named volumes unless you ask it to.
Do not casually use:
docker compose down -v
That removes named volumes declared in the Compose file and anonymous volumes attached to containers. For databases and stateful apps, that can mean deleting real data.
Use down -v only when you mean “destroy this environment”.
Restarting the Service
Restart the systemd unit:
sudo systemctl restart myapp.service
This runs the stop command and then the start command.
For only restarting containers without recreating them:
cd /opt/myapp
docker compose restart
Important distinction:
docker compose restartrestarts existing containers.docker compose up -dapplies config or image changes by recreating containers when needed.
If you changed compose.yaml, use:
docker compose up -d
Not just:
docker compose restart
Handling Orphan Containers
If you rename or remove a service in compose.yaml, old containers may remain as orphans.
Use:
docker compose up -d --remove-orphans
That is why the systemd service examples in this guide use:
ExecStart=/usr/bin/docker compose up -d --remove-orphans
It keeps the stack closer to the current Compose file.
Backups
Backups depend on the workload, but the principles are stable.
For bind mounts:
/opt/myapp/data/
Back up that directory.
For named volumes:
docker volume ls
Inspect a volume:
docker volume inspect volume-name
For databases, filesystem copies are not always enough. Use application-aware backups:
PostgreSQL example:
docker compose exec -T db pg_dump -U postgres appdb > backups/appdb.sql
MariaDB example:
docker compose exec -T db mariadb-dump -u root -p appdb > backups/appdb.sql
Redis example:
docker compose exec redis redis-cli BGSAVE
A Compose stack without a backup plan is not a service — it is a temporary experiment that happens to have uptime.
Security Baseline
For a small Compose service on Linux, start with this baseline:
- Keep the Compose project under
/opt/appname. - Use explicit image tags, not only
latest, when stability matters. - Use bind mounts or named volumes deliberately.
- Do not expose ports you do not need.
- Put public services behind a reverse proxy.
- Use HTTPS at the edge.
- Keep secrets out of Git.
- Restrict
.envpermissions. - Avoid privileged containers unless truly required.
- Avoid mounting the Docker socket into containers.
- Keep Docker and images updated.
- Test firewall behavior from another machine.
A dangerous pattern:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
This gives the container control over Docker. In practice, that can become host-level control. Use it only when you understand the risk.
Resource Limits
On small servers, one bad container can consume the host.
Compose supports resource-related settings, but behavior can depend on Docker Engine and Compose version. For simple protection, start with application-level limits and Docker logging limits.
For some workloads, you can add memory limits:
services:
app:
image: example/app:stable
restart: unless-stopped
mem_limit: 512m
Also configure app-level worker counts, queue limits, and cache sizes. Container limits are useful, but they are not a substitute for understanding the application.
Example: A Realistic Compose Service
Directory:
/opt/whoami/
compose.yaml
.env
Compose file:
services:
whoami:
image: traefik/whoami:v1.10
restart: unless-stopped
ports:
- "${WHOAMI_PORT}:80"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost || exit 1"]
interval: 30s
timeout: 5s
retries: 3
.env file:
WHOAMI_PORT=8080
COMPOSE_PROJECT_NAME=whoami
systemd unit:
[Unit]
Description=Whoami Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/whoami
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecReload=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
Install it:
sudo systemctl daemon-reload
sudo systemctl enable --now whoami.service
Test:
curl http://localhost:8080
Check status:
systemctl status whoami.service
cd /opt/whoami
docker compose ps
Troubleshooting
Service Starts but Containers Are Not Running
Check systemd:
journalctl -u myapp.service -n 100 --no-pager
Validate Compose:
cd /opt/myapp
docker compose config
Check Docker:
systemctl status docker
docker info
WorkingDirectory Is Wrong
If systemd cannot find your Compose file, confirm:
WorkingDirectory=/opt/myapp
Then check:
ls -la /opt/myapp
ls -la /opt/myapp/compose.yaml
The service runs from WorkingDirectory, not from your current shell directory.
Docker Permission Denied
If the unit runs as root, it can normally access Docker.
If you set User=someuser, that user must be able to access Docker. Usually that means membership in the docker group, or a rootless Docker setup.
Check:
groups someuser
Add the user if appropriate:
sudo usermod -aG docker someuser
Be careful. The Docker group is effectively privileged.
Compose Command Not Found
Find Docker:
command -v docker
Use the full path in the unit:
ExecStart=/usr/bin/docker compose up -d --remove-orphans
If Compose plugin is missing:
docker compose version
Install it using your Docker package source.
Environment Variables Are Missing
Check the Compose config as systemd would see it:
cd /opt/myapp
docker compose config
If systemd needs extra environment variables, use:
EnvironmentFile=-/opt/myapp/.env.systemd
If Compose needs variables for substitution, use:
/opt/myapp/.env
These are related, but not identical.
Containers Do Not Start After Reboot
Check whether the systemd service is enabled:
systemctl is-enabled myapp.service
Enable it:
sudo systemctl enable myapp.service
Check Docker:
systemctl is-enabled docker
systemctl status docker
Check boot logs:
journalctl -u myapp.service -b --no-pager
App Starts Before Database Is Ready
Add a database health check and depends_on with service_healthy.
Also fix the application. It should retry database connections. Infrastructure startup ordering is helpful, but application retry logic is better.
Disk Filled with Docker Logs
Check Docker disk usage:
docker system df
Check large container logs:
sudo du -h /var/lib/docker/containers | sort -h | tail
Configure Docker log rotation in /etc/docker/daemon.json.
Then recreate containers.
Common Mistakes
Mistake 1: Running docker compose up in rc.local
Running docker compose up from rc.local or a login script works until it does not — use a proper systemd unit instead.
Mistake 2: Using Restart=always in systemd and restart: always in Compose
Usually you only need container restart policies in Compose. Avoid two supervisors fighting each other.
Mistake 3: Forgetting –remove-orphans
Service renames and removals can leave old containers behind. Use:
docker compose up -d --remove-orphans
Mistake 4: Using docker compose restart After Config Changes
restart restarts containers. It does not apply all configuration changes.
Use:
docker compose up -d
Mistake 5: Running down -v Without Thinking
This can delete volumes. For stateful services, that can mean deleting data.
Mistake 6: No Backup Before Pull
New images can break. Databases can migrate. Tags can move. Back up first.
Mistake 7: Publishing Every Port
Only publish what the host needs to expose. Internal service-to-service traffic can stay on the Compose network.
Final Recommended Pattern
For most single-host Linux services, use this pattern:
Compose file:
services:
app:
image: example/app:stable
restart: unless-stopped
ports:
- "8080:8080"
env_file:
- .env
systemd unit:
[Unit]
Description=MyApp Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecReload=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120
[Install]
WantedBy=multi-user.target
Enable it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
Operate it:
sudo systemctl status myapp.service
sudo systemctl restart myapp.service
journalctl -u myapp.service -f
cd /opt/myapp && docker compose logs -f
This pattern is not fancy, and that is the point. Docker Compose is excellent for small, understandable systems, systemd is excellent at starting and stopping host services, and together they give you a reliable single-server deployment model without pretending every project needs a cluster. For container-level commands outside Compose — images, volumes, networks, and cleanup — see the Docker Cheatsheet.