docker compose images -q reports the image IDs of currently running containers, which don't change after a pull — so before == after always and containers were never recreated. Fix: resolve each service's image tag to its local SHA256 ID via docker image inspect, which reads the local image store and correctly reflects the newly pulled image. Falls back from 'config --images' (compose v2.19+) to parsing 'config' yaml for older versions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stacks
Self-contained Docker Compose stacks for pivoine.art infrastructure.
Each stack is independently deployable with its own compose.yml and .env. All persistent data lives in ../.data/<stack>/.
Stacks
| Stack | Description | Services |
|---|---|---|
traefik |
Reverse proxy, TLS termination | traefik |
mailpit |
SMTP relay (no web UI) | mailpit |
umami |
Web analytics | umami, db |
immich |
Photo & video management | immich, ml, redis, db |
n8n |
Workflow automation & notification relay | n8n, db |
gitea |
Git hosting + CI runner | gitea, runner, db |
coolify |
Deployment platform | coolify, realtime, redis, db |
passbolt |
Password manager (GPG-encrypted, team sharing) | passbolt, db |
code |
Browser-based VS Code IDE with Anthropic API access | code |
Tools
| File | Description |
|---|---|
stacks.sh |
CLI to manage stacks, services, scaffolding, updates, and backups |
.env |
Root config: WEBHOOK_URL, RESTIC_REPOSITORY, RESTIC_PASSWORD (gitignored) |
.env.example |
Template for the root .env |
stacks.sh
stacks.sh is the primary management CLI. It wraps docker compose with glob-based multi-stack targeting, manages the update and backup systemd services, scaffolds new stacks, and generates shell completions.
./stacks.sh help
Stack commands — all accept one or more stack names or glob patterns (omit for all stacks):
./stacks.sh ls # list all stacks with live container status
./stacks.sh ps gitea # container status table
./stacks.sh up # start all stacks
./stacks.sh up gitea traefik # start specific stacks
./stacks.sh down 'g*' # stop stacks matching glob
./stacks.sh restart 'g*,traefik' # glob + exact name, comma-separated
./stacks.sh pull --parallel # pull all images in parallel
./stacks.sh logs -f gitea # follow logs
./stacks.sh logs -n 100 gitea n8n # tail multiple stacks
./stacks.sh exec gitea gitea gitea admin user list # exec in container
./stacks.sh run passbolt passbolt bin/cake passbolt healthcheck
Service management (reads WEBHOOK_URL, RESTIC_REPOSITORY, RESTIC_PASSWORD from root .env):
./stacks.sh update install # write & enable systemd update timer
./stacks.sh update run # run update now
./stacks.sh update status # show timer/service status
./stacks.sh update logs # show journal logs
./stacks.sh backup install # write & enable systemd backup timer
./stacks.sh backup run # run backup now (auto-detects <stack>_db containers)
./stacks.sh backup snapshots # list restic snapshots
Scaffold a new stack:
./stacks.sh new myapp # basic stack with Traefik labels
./stacks.sh new myapp --db postgres # with Postgres service
./stacks.sh new myapp --db postgres --redis # with Postgres + Redis
./stacks.sh new myapp --no-traefik # expose port instead of Traefik
Generates compose.yml (with healthchecks, ../.data/ volumes, Traefik labels) and .env.example.
Shell completion:
# Dynamic — discovers stacks at tab-complete time (default)
./stacks.sh completion zsh --install
# Static — bakes current stack list in; useful on the VPS
./stacks.sh completion zsh --static --install
Global flags: --dry-run, --parallel, --verbose, --quiet
Deployment
# Copy example env and fill in secrets
cp <stack>/.env.example <stack>/.env
# Sync a stack to VPS
rsync -avz <stack>/ vps:~/stacks/<stack>/
# Start a stack
ssh vps 'cd ~/stacks/<stack> && docker compose up -d'
Network
All stacks share the external falcon_network Docker network for inter-service communication (e.g. traefik routing, mailpit SMTP).
Backup
Runs daily at 3:00 AM via a systemd timer. Detects Postgres databases automatically by convention (<stack>_db container, user <stack>, database <stack>), dumps each one, then runs a full restic backup of .data/. Retention: 7 daily, 4 weekly, 6 monthly. Notifications go to Telegram via n8n.
# First-time setup on VPS
cp .env.example .env && $EDITOR .env # set RESTIC_REPOSITORY, RESTIC_PASSWORD, WEBHOOK_URL
restic init # initialise restic repo (uses vars from .env)
./stacks.sh backup install # write & enable systemd unit + timer
./stacks.sh backup run # test run
./stacks.sh backup snapshots # list snapshots
./stacks.sh backup status # timer/service status
./stacks.sh backup logs # journald logs
Updates
Runs nightly at 2:00 AM via a systemd timer. Pulls the latest image for every stack, recreates any container whose image changed, prunes dangling images, and sends a Telegram notification via n8n.
./stacks.sh update install # write & enable systemd unit + timer
./stacks.sh update run # test run
./stacks.sh update status # timer/service status
./stacks.sh update logs # journald logs
Notifications
The update script and the backup script both POST to an n8n webhook, which forwards messages to Telegram.
The webhook URL is set in:
_backup/.env→WEBHOOK_URL_update/.env→WEBHOOK_URL
Both point to the same n8n workflow at https://n8n.pivoine.art. The workflow accepts { "message": "..." } and forwards it to Telegram.
Data
Persistent data is stored in ~/stacks/.data/<stack>/ on the VPS using bind mounts. Database stacks use dedicated Postgres instances with simple credentials.