Tiny HTTP service that pulls a new Docker image and recreates a container on demand.
Push-based alternative to Watchtower: instead of polling a registry every N seconds, your CI calls a webhook the moment the new image is pushed, and the container is updated within seconds — with zero idle bandwidth.
POST /notify/{container_name} — for each request:
- Inspects the running container with that name.
- Pulls the image it's configured to use (same registry, same tag).
- Compares the resulting image digest to the running container's digest.
- If it changed: stops the container, removes it, recreates it from the same config + new image, reconnects networks, starts it.
- If it didn't change: logs
already up to dateand does nothing.
Failures are logged and swallowed — the next webhook gets a fresh attempt. There is no retry, no rollback, no state.
- Faster. Watchtower's default poll interval is 60s+; webhooks land in <1s.
- Cheaper. No constant
HEADrequests against your registry. - Targeted. Only the container that actually changed is touched. No accidental restarts of unrelated services that happen to share a label.
- Requests are accepted immediately (returns
202 Accepted) and enqueued in an in-memory bounded channel. - A single worker drains the queue. Containers are never processed in parallel — 500 concurrent webhooks won't tear down 500 containers at once.
- Duplicate requests for the same container that arrive in the same batch are coalesced into a single update.
- If the queue fills (default capacity: 1024), excess requests return
503 Service Unavailable. They are not persisted across restarts.
| Method | Path | Response |
|---|---|---|
POST |
/notify/{container_name} |
202 queued · 401 missing/bad secret · 503 queue full · 400 empty name |
GET |
/health |
200 ok (unauthenticated) |
/notify requires Authorization: Bearer <secret> where <secret> matches the SECRET_KEY env var. Requests with a
missing, malformed, or non-matching header return 401 instantly and are never enqueued. The secret is compared in
constant time.
/health is unauthenticated so liveness/readiness probes don't need credentials.
docker run -d \
--name trigger-docker-pull \
--restart unless-stopped \
-e SECRET_KEY=$(openssl rand -hex 32) \
-p 8080:8080 \
-v /var/run/docker.sock:/var/run/docker.sock \
trigger-docker-pull:latestThen, from your CI pipeline after pushing a new image:
curl -fsS -X POST \
-H "Authorization: Bearer $SECRET_KEY" \
https://your-host.example.com/notify/my-apiservices:
trigger-docker-pull:
image: trigger-docker-pull:latest
container_name: trigger-docker-pull
environment:
SECRET_KEY: ${SECRET_KEY}
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sockMount a Docker config.json to authenticate pulls:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-config.json:/root/.docker/config.json:roThe file uses Docker's standard format:
{
"auths": {
"ghcr.io": {
"auth": "base64(username:password_or_token)"
}
}
}Both bare (ghcr.io) and prefixed (https://ghcr.io, https://index.docker.io/v1/) registry keys are supported.
Override the path with DOCKER_CONFIG_PATH.
- name: Build and push image
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
- name: Trigger deploy
env:
SECRET_KEY: ${{ secrets.PUSH_TRIGGER_SECRET_KEY }}
run: |
curl -fsS -X POST \
-H "Authorization: Bearer $SECRET_KEY" \
https://deploy.example.com/notify/my-api| Variable | Default | Notes |
|---|---|---|
SECRET_KEY |
required | Bearer token required on every /notify request. Service refuses to start if unset or empty. |
PORT |
8080 |
TCP port the HTTP server binds to. |
QUEUE_SIZE |
1024 |
In-memory queue capacity. Excess returns 503. |
DOCKER_CONFIG_PATH |
/root/.docker/config.json |
Where to read registry credentials from. |
RUST_LOG |
info |
Log level. Standard tracing filter syntax. |
The service reads the container's full inspect response, then on update:
docker stop(10s grace) — best-effort, errors ignored.docker rm --force.docker createwith the same config, host config, image (new), and labels.- Reattaches each user-defined network the old container was on, preserving aliases.
docker start.
This is similar to what Watchtower does — there is no in-place "update image" Docker API, only stop/remove/create.
- Brief downtime. Stop → remove → create → start takes a few seconds depending on the image. There is no zero-downtime rolling update; that's a job for an orchestrator.
- State. If the container had ephemeral state inside its writable layer (it shouldn't), it's gone after recreate. Use volumes for anything you want to keep.
- Failure mode. If the new image fails to pull or the container fails to start, the old container is already gone. The service logs and moves on — your monitoring should catch a down container.
cargo build --release
./target/release/trigger-docker-pullOr build the container locally:
docker build -t trigger-docker-pull .The release binary is statically linked (musl) and the runtime image is FROM scratch, so the final image is just the
stripped binary — a few MB.
MIT — see LICENSE.