Docker & ContainersXXXVI · AutomationAutomation
Automation with shell, systemd, and Ansible
What you'll learn
- State the idempotence problem and recognise it in a shell script
- Write a systemd unit that supervises a container rather than the Docker CLI
- Avoid the double restart policy that makes systemd and Docker fight
- Choose between shell, systemd and Ansible on the basis of what each guarantees
- Verify an automation actually converged, rather than that it exited zero
Prerequisites
None — start here.
Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12
Automation is usually described as “replacing manual steps with a script”. That description is why so much of it is dangerous. The property that matters is not that a machine runs the steps instead of a person; it is that running the steps again is safe.
Almost every Docker automation defect traces back to that one property.
The idempotence problem
Here is the entire problem in two commands.
$ docker run -d --name web -p 8080:80 nginx:1.27
docker run -d --name web -p 8080:80 nginx:1.27a3f1c2b8e4d9...
docker: Error response from daemon: Conflict. The container name "/web" is
already in use by container "a3f1c2b8e4d9". You have to remove (or rename)
that container to be able to reuse that name.Illustrative output
The second invocation fails. Under set -e — which every competent shell
script has — the script aborts, and every step after it never runs. Your
“deploy” script now half-deploys, and which half depends on whether the
container happened to exist.
Now drop the --name to avoid the conflict, which is the usual instinct:
$ docker run -d -p 8080:80 nginx:1.27
docker run -d -p 8080:80 nginx:1.27b71d0aa4f52c...
docker: Error response from daemon: driver failed programming external
connectivity on endpoint quirky_bose: Bind for 0.0.0.0:8080 failed: port is
already allocated.Illustrative output
Still fails, for a different reason, and now you have an anonymous container you cannot find by name. Remove the port publication too and it finally “succeeds” — by leaving two identical containers running, both connected to the application network, both registered in Docker’s embedded DNS under the same service alias, splitting traffic between an old build and a new one.
That is the failure that costs a night: the deploy script exits zero, the dashboards look fine, and roughly half of requests hit an image from three weeks ago.
docker run is not idempotent, and it has no flag that makes it so.
There is no --if-not-exists, no --replace, no declarative mode. This is
not an oversight — docker run is an imperative command that creates a
thing. The gap is real, and everything in the rest of this lesson exists to
fill it.
Three grades of shell idempotence
Shell can approximate idempotence. It is worth knowing the grades, because the difference between them is the difference between a script that works and a script that hides failures.
NET=app-net
# Grade 1: swallow every error. Never do this.
# A typo, a permission failure and a daemon outage are all "fine" now.
docker network create "$NET" 2>/dev/null || true
# Grade 2: check first. Better, but it is a check-then-act race, and
# it still cannot detect that the network exists with the WRONG subnet.
docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET"
# Grade 3: check first, and check the properties you care about.
if docker network inspect "$NET" >/dev/null 2>&1; then
SUBNET=$(docker network inspect "$NET" \
--format '{{range .IPAM.Config}}{{.Subnet}}{{end}}')
if [ "$SUBNET" != "192.0.2.0/24" ]; then
echo "FAIL: $NET exists with subnet $SUBNET, expected 192.0.2.0/24" >&2
exit 1
fi
else
docker network create --subnet 192.0.2.0/24 "$NET"
fiGrade 1 is the one you will find in most blog posts, and it is worse than no error handling at all: it converts every failure, including the ones you desperately want to know about, into silence.
Grade 3 is what an Ansible module does internally, and writing it out makes the cost obvious. That is roughly fifteen lines to make one resource converge. A Compose stack has containers, networks, volumes and images. Doing this properly in shell for all of them is several hundred lines of error-prone code that duplicates, badly, something that already exists.
That is the honest argument for the tools. Not “shell is unprofessional” — shell is fine — but that convergence is a solved problem and re-solving it per-script is where the bugs live.
systemd for containers
systemd’s contribution is different from Ansible’s. Ansible converges state
when you run it; systemd keeps state converged, restarts things that
die, orders startup, and gives you systemctl status and journalctl as
the interface. For a single host, that is often exactly the missing piece.
It also has one trap that catches nearly everyone.
The correct shape runs the container in the foreground so that the process systemd supervises is the one whose lifetime matches the container.
[Unit]
Description=web (nginx container)
After=docker.service network-online.target
Requires=docker.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=70
# Remove a container left behind by an unclean shutdown. The leading "-"
# means systemd ignores a non-zero exit, which is the normal case: on a
# clean start there is nothing to remove.
ExecStartPre=-/usr/bin/docker rm -f web
# Foreground. No -d. --rm so a crashed container does not block the next
# start. --sig-proxy is on by default, so SIGTERM reaches PID 1 in the
# container.
ExecStart=/usr/bin/docker run --rm --name web \
--publish 127.0.0.1:8080:80 \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
--security-opt no-new-privileges=true \
nginx:1.27
# docker stop sends SIGTERM then SIGKILL after its own grace period.
ExecStop=/usr/bin/docker stop --timeout 30 web
[Install]
WantedBy=multi-user.targetDirective by directive, because each of these is a bug you would otherwise find in production:
After=docker.serviceandRequires=docker.service.After=orders;Requires=creates the dependency. You need both.After=alone means “if docker is being started, start after it” — on a boot where something maskeddocker.service, your unit starts anyway and fails.Type=simplewith a foregrounddocker run. The supervised process is the CLI, which now stays alive for as long as the container does and proxies signals to it.docker run -dis what breaks this.ExecStartPre=-/usr/bin/docker rm -f web. After a hard power loss the container record survives and the nextdocker run --name webfails with a name conflict — the idempotence problem, arriving via a different door. The leading-makes the ordinary “no such container” exit non-fatal.RestartSec=10withStartLimitBurst=5overStartLimitIntervalSec=300. Without a rate limit, a container that fails to start restarts forever and saturates the disk with image-layer churn and journal writes. With it, five failures in five minutes puts the unit intofailedand stops, which is a state a human can see. Note the twoStartLimit*directives belong in[Unit], not[Service]— misplacing them is silent, and they simply do not apply.TimeoutStopSec=70againstdocker stop --timeout 30. systemd’s stop timeout must exceed Docker’s, or systemdSIGKILLs the CLI while Docker is still waiting out its own grace period, and the container is orphaned mid- shutdown. Leave headroom.
Compose stacks under systemd
A Compose stack has many containers, so the foreground trick does not apply. The accepted shape is different, and its limitations should be stated plainly.
[Unit]
Description=myapp Compose stack
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/myapp
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up --detach --remove-orphans --wait
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=600
[Install]
WantedBy=multi-user.targetType=oneshotwithRemainAfterExit=yes. The command exits, and systemd keeps the unitactiveanyway. That is the whole point of this pattern.ExecStartPre=docker compose config --quiet. Parses and validates the Compose file, including variable interpolation, and fails the unit before anything is touched. Without it, an unset variable produces a stack that comes up misconfigured at boot with no obvious error.--wait. Blocks until services are running and, for services with a healthcheck, healthy. Without itdocker compose up -dreturns as soon as the containers are created, so systemd reports the unit active while the application is still starting — and any unit orderedAfter=this one starts too early.--remove-orphans. Removes containers from services you deleted from the Compose file. Otherwise they keep running, forever, invisible todocker compose ps.
Ansible
Ansible’s contribution is the third thing: convergence across many hosts, expressed declaratively, with a change record.
- name: Install Docker Engine
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Configure the Docker daemon
ansible.builtin.copy:
content: "{{ docker_daemon_config | to_nice_json }}"
dest: /etc/docker/daemon.json
owner: root
group: root
mode: '0644'
validate: 'python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s'
notify: restart docker
- name: Ensure Docker is running and enabled
ansible.builtin.systemd_service:
name: docker
state: started
enabled: true
daemon_reload: true
- name: Run the application container
community.docker.docker_container:
name: web
image: "nginx:{{ nginx_version }}"
state: started
restart_policy: unless-stopped
published_ports:
- '127.0.0.1:8080:80'
memory: 512M
read_only: true
security_opts:
- 'no-new-privileges=true'Two lines carry more weight than they look:
validate:on thedaemon.jsoncopy. Ansible runs the validation command against the staged file and refuses to install it if the command fails. Without this, a templating mistake writes malformed JSON, the handler restarts Docker, anddockerdrefuses to start — taking every container on the host with it. This one line is the difference between a failed play and an outage.community.docker.docker_containerrather thandocker_container. The fully-qualified name is not pedantry: short names resolve through a search path that depends on which collections are installed, and a playbook that works on the control node and fails in CI is usually this.
A backup script, corrected
The volume-backup script is the most-copied piece of Docker shell automation, and the common version has two real defects.
#!/bin/bash
# Back up named Docker volumes. Anonymous volumes are deliberately skipped:
# their names are 64-hex strings that mean nothing on restore.
set -euo pipefail
STAMP=$(date -u +%Y-%m-%d)
BACKUP_DIR="/var/backups/docker/$STAMP"
S3_PREFIX="s3://myorg-backups/docker/$STAMP"
mkdir -p "$BACKUP_DIR"
# Filter out anonymous volumes: 64 lowercase hex characters, nothing else.
docker volume ls --format '{{.Name}}' \
| grep -Ev '^[0-9a-f]{64}$' > "$BACKUP_DIR/volumes.txt"
while read -r VOL; do
[ -n "$VOL" ] || continue
echo "Backing up volume $VOL"
docker run --rm \
--network none \
--user 0:0 \
-v "$VOL":/source:ro \
-v "$BACKUP_DIR":/backup \
alpine:3.20 \
tar czf "/backup/$VOL.tar.gz" -C /source .
done < "$BACKUP_DIR/volumes.txt"
# BACKUP_DIR is absolute, so syncing it directly would produce an S3 key of
# s3://bucket/docker/2026-08-12/var/backups/docker/2026-08-12/... Sync the
# directory CONTENTS to the prefix instead.
aws s3 sync "$BACKUP_DIR/" "$S3_PREFIX/"
# Verify each archive is readable before declaring success. A tar that was
# truncated by a full disk exits zero on creation and fails here.
for ARCHIVE in "$BACKUP_DIR"/*.tar.gz; do
tar tzf "$ARCHIVE" >/dev/null || { echo "CORRUPT: $ARCHIVE" >&2; exit 1; }
done
echo "Backup verified: $(ls -1 "$BACKUP_DIR"/*.tar.gz | wc -l) archives"Choosing between the three
| Shell | systemd | Ansible | |
|---|---|---|---|
| Converges state | Only what you write | No — it starts and supervises | Yes, per resource |
| Survives reboot | No | Yes | No (it runs when you run it) |
| Restarts on failure | No | Yes | No |
| Multiple hosts | Copy it around | No | Yes, with inventory |
| Records what changed | No | Journal, per start | Yes, changed/ok per task |
| Dry run | No | No | --check --diff |
They are complements, not alternatives, and the usual mature arrangement uses all three: Ansible converges the host and writes the unit files, systemd runs and supervises the workloads, and shell handles the genuinely one-shot things that have no state to converge.
- Write the operation once, by hand, and record every command and its output. You cannot automate a procedure you have not performed.
- Make it idempotent, then prove it by running it twice on a scratch host and diffing the state afterwards. Running twice is the test, not reading the code.
- Give it a dry run.
--checkin Ansible,docker compose configfor a stack, or an explicitDRY_RUNguard in shell. An automation you cannot rehearse is one you will only ever run in anger. - Take a lock before touching the daemon, so two automations cannot race.
- Verify convergence, not exit status. Exit zero means the commands ran. Query the resulting state and compare it to what you intended.
- Fail loudly.
2>/dev/null || trueis how a broken automation runs green for six months.
Knowledge check
Knowledge check · 6 questions
Q1. A systemd unit uses `ExecStart=/usr/bin/docker run -d --name web nginx:1.27` with `Type=simple`. What goes wrong?
Q2. Why is `docker network create app-net 2>/dev/null || true` a poor idempotence pattern?
Q3. Which are true when a container has both `--restart unless-stopped` and a systemd unit with `Restart=always`? Select all that apply.
Q4. In a Compose unit, why add `--wait` to `docker compose up --detach`?
Q5. A container started by a foreground `docker run` in a systemd unit is parented into a containerd shim, so `MemoryMax=` in the unit does not constrain it.
Q6. Which Ansible flag pair lets you see what a play would change to a Docker host before it changes it?
Passing score: 75%. Answers are checked in this browser.