Restart Is Not Deploy: A Pre-Flight Gate for the API Container
A restart gate that refuses while the tree is dirty.
TL;DR
Restarting a bind-mounted container deploys whatever sits in the working tree, so a half-finished refactor went live uncommitted. The fix is a restart script that refuses to run when the served path is dirty, using git status porcelain. The guard narrows but doesn't close the race window between check and start.
I was halfway through tearing apart the score module in ansible/web/ when I casually decided to restart the API container server-old-api-1. Just to freshen the service up. Seconds later the health dashboard showed a dash over 100 in every score ring.
My first guess: the image must be broken. The reasoning felt sound. I restarted the container, I did not rebuild it, so the running code should be identical to what ran before.
The reasoning missed one detail: the API service in compose runs with a bind mount from the repo root. What gets served is not a build artifact baked into an image but the working tree on the host, exactly as it is [1]. My half-finished refactor went live with one restart I thought was routine.
Restart is stop-start, not deploy
The container restart command only stops and starts the same container: it sends SIGTERM first, then SIGKILL once the timeout runs out [3]. No new image gets pulled, no layers get rebuilt. uvicorn loads code from disk when the process starts, and because the app path is a mount, what gets reloaded is my working tree in that exact moment [4].
So restart and deploy are two different operations. In a setup without images, a restart actually holds deploy power: it executes whatever happens to be on disk, including files that never made it into any commit.
The twenty-line gate in restart_api.sh
I put the fix in front of the door instead of on a sticky note. The core of scripts/restart_api.sh:
#!/usr/bin/env bash
# Guarded api restart: refuses when ansible/web/ is dirty.
# Why: docker-compose bind-mounts ./ into server-old-api-1 — uvicorn serves the
# WORKING TREE, so a restart with uncommitted ansible/web/ code ships stale
# code live (incident 2026-09-18: score rings went '–/100').
# Usage: scripts/restart_api.sh [--check] (--check = guard only, no restart)
set -euo pipefail
REPO="$(cd "$(dirname "$0")/.." && pwd)"
DIRTY=$(git -C "$REPO" status --porcelain -- ansible/web)
if [ -n "$DIRTY" ]; then
echo "REFUSED: ansible/web/ is dirty — commit or stash first:" >&2
echo "$DIRTY" >&2
exit 1
fi
if [ "${1:-}" = "--check" ]; then
echo "GUARD OK: ansible/web/ clean at HEAD $(git -C "$REPO" rev-parse --short HEAD)"
exit 0
fi
docker restart server-old-api-1
The probe deliberately uses the --porcelain=v1 format: its output is guaranteed stable across Git versions and unaffected by user configuration, which makes it safe for scripts to parse [2]. Empty output means clean. Anything in it and the container refuses to move: the script prints the offending files and exits with exit 1.
The --check flag exists for cron jobs or CI probes: it runs the guard only, without touching the container. When the guard passes, the last line brings server-old-api-1 back up, and the script closes with a hint to verify health_score through a curl call.
The window that stays open
This gate narrows the window, it does not close it. A file can still change in the gap between a passing probe and the new process actually starting. A clean tree is not automatically a tested tree either; clean means exactly one thing: the content matches the last commit, including any untracked file that happens to sit there.
Two small choices keep the guard trustworthy. The git probe runs under set -euo pipefail, so if the status command itself fails, the script stops too; failing means something different from clean. And the check scope is deliberately only ansible/web, the path that is actually served; the rest of the repo is allowed to be dirty at restart time.
This pattern belongs to the same family as the reboot playbook that refuses to run while apt holds its locks: different mechanism, same principle. Operations that mutate the state of a live service deserve a gate first. If a container can ship code I never committed, I do not have a deploy process; I have a lottery.