falcon-vpn-only was defined inline in falcon.yaml even though it's a
generic ipAllowList restriction with nothing falcon-specific about it
- security.yaml is where the other reusable middlewares (security-
headers, no-index, rate-limit) already live. Renamed to vpn-only to
match their bare naming style now that it's not tied to one file, and
updated falcon.yaml's two router references to vpn-only@file (same
cross-file pattern already used there for security-headers@file).
Also applies it to the pulsenode stack: it's a dashboard surfacing
container names/health for every service here, and there's no auth in
front of it yet, so gating it to the tailnet is a real mitigation for
that gap rather than leaving it open on the public hostname.
Infrastructure dashboard monitoring all the stacks in this repo, built
via stacks new pulsenode and then hand-adjusted for what a generic
scaffold can't know: pulls the published image
(dev.pivoine.art/valknar/pulsenode:latest) instead of building, mounts
/var/run/docker.sock:ro plus its own config/ directory read-only, runs
read-only/non-root/cap-dropped (group_add on DOCKER_GID to get socket
access without running as root), and uses its own healthcheck.mjs
rather than curl (not present in its minimal image).
config/config.yml is real, not a placeholder: docker/database widgets
for every container across all ten stacks here, discovery enabled
against falcon_network as a safety net for anything added later.
Add a Traefik IP allowlist so *.falcon.pivoine.art rejects any source
outside the Tailscale/Headscale CIDR ranges, even though DNS still
resolves publicly. Pair it with a headscale MagicDNS override so
tailnet clients resolve the hostname straight to the VPS's tailscale
IP and route correctly.
Requires Docker's userland-proxy disabled on the VPS host
(/etc/docker/daemon.json) so Traefik sees real client source IPs
instead of the docker bridge gateway — done manually, not tracked
in this repo.
Dark Frappé base (#303446) background with the pivoine.art peony
centered and padded. Replaces logo.svg/png, favicon.svg/png, and
apple-touch-icon.png.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port 0 disabled the web UI but broke the built-in /readyz healthcheck.
Binding to 8025 keeps the UI accessible within the Docker network only
(no Traefik labels) and lets the container report healthy.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
IONOS rejects relay from VPS IPs when sender doesn't match authenticated
account. Switch gitea and passbolt FROM to the actual IONOS account address.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
stacks.sh now lives in git@dev.pivoine.art:valknar/scripts.git
and resolves STACKS_DIR from env or defaults to ~/stacks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same fix as the update service: gate "not installed" on unit file
existence rather than systemctl exit code, which is non-zero for
failed services too.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
systemctl status exits non-zero for failed services (exit 3), not
only missing ones (exit 4), causing the || warn branch to always fire
after a failed run. Gate the "not installed" warning on whether the
unit file exists instead.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docker image inspect returns exit code 1 for images not yet pulled.
With set -euo pipefail, this silently killed the script mid-loop.
Add || true so an uncached image yields empty before/after (correct
first-run behaviour: pull fires, before != after, stack gets started).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Docker binds to both [::]:22 and 0.0.0.0:22 without an explicit IP,
conflicting with sshd on [::]:22.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ondemand kills all workers after 10s idle, causing cold Laravel
bootstrap (10-54s) on every request after any quiet period.
dynamic keeps min_spare_servers=1 alive at all times; the other
PM_* values (start_servers=2, min=1, max_spare=3) were already
set but ignored under ondemand.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
74.7.243.196 was crawling valknar/home SVG icon tree at 1.8 req/s.
- Denylist the IP in fail2ban dynamic config
- Add rate-limit@file middleware to Gitea's Traefik router to throttle
future scrapers on public repos
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
v0.7.1 caused 403 on all routes. v0.9.0 renamed whitelist→allowlist,
changed ip to a comma-separated string, and added enabled/statuscode fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Registers tomMoulard/fail2ban v0.7.1 as an experimental plugin and
applies it globally on the web-secure entrypoint alongside security-headers.
Bans IPs after 5 failures within 10 minutes for 3 hours; whitelists
RFC1918 ranges to protect internal/Docker traffic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Source .env before restic init so RESTIC_REPOSITORY is available
- Remove stale references to deleted _backup/.env and _update/.env
- Update Notifications section to point at root .env
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>
- Inline update logic (pull → compare digests → up -d → prune → notify)
- Inline backup logic with dynamic Postgres detection: any running
<stack>_db container is dumped using the <stack>/<stack> convention
- Systemd unit files are now generated on `install` from embedded
heredocs pointing at stacks.sh itself — no external scripts needed
- Root .env (WEBHOOK_URL, RESTIC_REPOSITORY, RESTIC_PASSWORD) replaces
the per-service .env files in _backup/ and _update/
- Remove _backup/ and _update/ directories entirely
- Update README accordingly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace manual docker compose / systemctl snippets with stacks.sh
equivalents and add a dedicated section covering all commands.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bakes the current stack list into the generated completion script instead
of using runtime directory discovery. Useful for remote hosts where the
stacks dir path differs from the local repo.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bash script for managing Docker Compose stacks with glob filtering,
parallel execution, service management, scaffolding, and shell completion.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Use image ID diff instead of grepping "Pulled" (which appears even
when images are already up to date). Add timestamp, stack count, and
updated/total ratio to all notification messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add +x permission to update.sh (was causing systemd 203/EXEC failure)
- Remove $ anchor from grep so trailing space in docker output is matched
- Add .data/ to .gitignore
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mount templates/custom/ directly to Gitea's default GITEA_CUSTOM templates
path. No entrypoint override or envsubst needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Injects the Umami tracking script into every Gitea page using a custom
header.tmpl generated at startup via envsubst. Script URL and website ID
are externalized to UMAMI_SCRIPT_URL / UMAMI_WEBSITE_ID env vars.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>