deploy: bring the quadlets in from lair/containers

These describe how this repo's image is run; they belong with it rather
than in the third-party image repo. Captured from the live units on bob,
with the postgres password redacted out of the DSN.

Records the two things that are easy to get wrong and expensive to
rediscover: the db -> server -> electric ordering (the server's migrations
create the role and publication Electric attaches to, and Electric's
Requires= means a server restart stops it without bringing it back), and
the firewalld/aardvark interaction that presents as an auth failure.

Image= is a pinned immutable tag and AutoUpdate=registry is gone:
podman-auto-update.timer is enabled on bob, so a floating tag would let
the timer decide which build is running.
This commit is contained in:
rob thijssen
2026-07-21 15:54:35 +03:00
parent de5d85504d
commit db061184bc
5 changed files with 195 additions and 0 deletions

55
deploy/readme.md Normal file
View File

@@ -0,0 +1,55 @@
# deploy
The podman quadlets for the self-hosted stack, as deployed on
`bob.hanzalova.internal` in `/etc/containers/systemd/`. Fronted by nginx on
`hanzalova.internal` as `https://kanban.internal`.
The image is built and published by `.gitea/workflows/container.yml` in this
repo as `git.lair.cafe/lair/vibe-kanban-remote`.
## Ordering is load-bearing
`db -> server -> electric`, and not merely cosmetically:
- The server's sqlx migrations are what create the `electric_sync` role and the
publication Electric attaches to, so Electric started first has nothing to
attach to.
- `Notify=healthy` on the server holds systemd in "starting" until `/v1/health`
answers, so Electric genuinely waits for the migrations rather than racing them.
- `vibe-kanban-electric` sets `Requires=vibe-kanban.service`, so restarting the
server stops Electric but does **not** bring it back. Restart it explicitly.
## Pinned images, deliberately
`Image=` names an immutable `${version}-g${sha}` tag, not `:latest`, and
`AutoUpdate=registry` is deliberately absent. `podman-auto-update.timer` is
enabled on bob, so a floating tag would mean the running version is decided by
whenever the timer last fired. Deploys here are a one-line edit plus a restart.
To roll forward: take the tag the container workflow prints, edit `Image=`,
`systemctl daemon-reload`, restart the server, then restart Electric.
## Secrets
`/etc/vibe-kanban/env` (mode 0600, root:root) holds the postgres DSN and the
local-auth settings; it mirrors `pass lair/vibe-kanban/*`. It is not in this
repo. Note the DSN embeds the password inline, so redact on the URL shape rather
than on variable names when pasting any of it around.
Auth is local single-account mode (`SELF_HOST_LOCAL_AUTH_*`) with no OAuth
provider; the server refuses to start unless at least one provider is
configured. Moving to a real IdP means adding a provider, not editing these
files.
## Networking
`vibe-kanban.network` is a private bridge with `dns_enabled`, so the containers
resolve each other by name via aardvark-dns on the bridge address. That requires
the bridge to sit in firewalld's `trusted` zone — `firewall-cmd --reload` drops
runtime-only zone assignments, which silently kills all DNS inside these
containers and surfaces as login 500s (`failed to fetch local auth user by
email ... Temporary failure in name resolution`). The assignment is made
permanent on bob; if it is ever lost:
firewall-cmd --permanent --zone=trusted --add-interface=podman1
firewall-cmd --reload

View File

@@ -0,0 +1,40 @@
# PostgreSQL for vibe-kanban.
#
# Deliberately NOT magrathea. magrathea is mTLS-only (`hostssl ... cert
# clientcert=verify-full map=cert_cn`, with `hostnossl ... reject`) and
# ElectricSQL cannot present a client certificate — it supports only
# sslmode=require/disable plus a CA file for verifying the *server*. Electric is
# not optional either: remote-server treats ELECTRIC_URL as mandatory. magrathea
# is also wal_level=replica, and Electric needs logical, which would mean
# restarting the shared primary. So this stack runs its own Postgres on a private
# podman network; nothing here is reachable from the LAN.
#
# Version tracks upstream's compose (postgres:16-alpine), not magrathea's 18.
# wal_level=logical is required by Electric's logical replication.
[Unit]
Description=vibe-kanban PostgreSQL
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/library/postgres:16-alpine
ContainerName=vibe-kanban-db
AutoUpdate=registry
Network=vibe-kanban.network
Exec=postgres -c wal_level=logical
Volume=/var/lib/vibe-kanban/postgres:/var/lib/postgresql/data:Z
Environment=POSTGRES_DB=remote
Environment=POSTGRES_USER=remote
EnvironmentFile=/etc/vibe-kanban/env
HealthCmd=pg_isready -U remote -d remote
HealthInterval=5s
HealthTimeout=5s
HealthRetries=10
HealthStartPeriod=5s
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,45 @@
# ElectricSQL sync service for vibe-kanban.
#
# Electric streams Postgres logical replication to the browser. remote-server
# treats ELECTRIC_URL as mandatory, so this is not optional.
#
# Starts AFTER vibe-kanban.service because the `electric_sync` role, its grants
# and the publication are created by remote-server's migrations — Electric cannot
# connect until they exist, and remote-server also sets that role's password on
# start. vibe-kanban.service uses Notify=healthy, so this really does wait.
#
# ELECTRIC_INSECURE mirrors upstream's own production compose and is contained:
# Electric publishes no host port and is reachable only by container name on the
# private vibe-kanban bridge. An `electric-secret` exists in pass if we later want
# to set ELECTRIC_SECRET here and on remote-server.
[Unit]
Description=vibe-kanban ElectricSQL sync
After=network-online.target vibe-kanban.service
Wants=network-online.target
Requires=vibe-kanban.service
[Container]
Image=docker.io/electricsql/electric:1.4.13
ContainerName=vibe-kanban-electric
AutoUpdate=registry
Network=vibe-kanban.network
Volume=/var/lib/vibe-kanban/electric:/app/persistent:Z
# Its own env file (not the shared one) so the variable can simply be named
# DATABASE_URL — what Electric reads — without colliding with remote-server's
# SERVER_DATABASE_URL, and with no reliance on systemd expanding one Environment=
# value into another, which quadlets do not do dependably.
EnvironmentFile=/etc/vibe-kanban/electric.env
Environment=PG_PROXY_PORT=65432
Environment=LOGICAL_PUBLISHER_HOST=vibe-kanban-electric
Environment=AUTH_MODE=insecure
Environment=ELECTRIC_INSECURE=true
Environment=ELECTRIC_MANUAL_TABLE_PUBLISHING=true
Environment=ELECTRIC_USAGE_REPORTING=false
Environment=ELECTRIC_FEATURE_FLAGS=allow_subqueries,tagged_subqueries
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,47 @@
# vibe-kanban remote-server — the self-hosted server half of the suite.
# Serves both the API and the built SPA (from /srv/static in the image) on :8081
# as uid 10001; published to the LAN on 27180 (agent-zero=5080, open-webui=5090,
# hermes=5100). Fronted by nginx on hanzalova.internal as https://kanban.internal.
#
# Image is built by lair/containers from OUR mirror of BloopAI/vibe-kanban, never
# from GitHub — upstream is sunsetting. AutoUpdate=registry picks up rebuilds.
#
# Startup order matters and is not merely cosmetic: this unit's sqlx migrations
# are what CREATE the `electric_sync` role and the publication that Electric then
# connects with, and it ALTERs that role's password from ELECTRIC_ROLE_PASSWORD on
# every start. So db -> this -> electric. Notify=healthy makes systemd hold the
# unit "starting" until /v1/health answers, so electric genuinely waits for the
# migrations rather than racing them.
#
# Auth: local single-account mode only (SELF_HOST_LOCAL_AUTH_*), deliberately no
# OAuth — the server refuses to start unless at least one provider is configured.
# Swapping to a real IdP later means adding a provider, not changing this file.
[Unit]
Description=vibe-kanban remote-server
After=network-online.target vibe-kanban-db.service
Wants=network-online.target
Requires=vibe-kanban-db.service
[Container]
Image=git.lair.cafe/lair/vibe-kanban-remote:0.1.44-gccff6fc
ContainerName=vibe-kanban
Network=vibe-kanban.network
PublishPort=27180:8081
EnvironmentFile=/etc/vibe-kanban/env
Environment=SERVER_LISTEN_ADDR=0.0.0.0:8081
Environment=ELECTRIC_URL=http://vibe-kanban-electric:3000
Environment=SERVER_PUBLIC_BASE_URL=https://kanban.internal
Environment=RUST_LOG=info,remote=info
Notify=healthy
HealthCmd=wget --spider -q http://127.0.0.1:8081/v1/health
HealthInterval=10s
HealthTimeout=5s
HealthRetries=12
HealthStartPeriod=20s
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,8 @@
# Private bridge for the vibe-kanban stack (postgres + electric + remote-server).
# Only remote-server publishes a host port (27180); postgres and electric are
# reachable only by container name on this network, never from the LAN.
[Network]
NetworkName=vibe-kanban
[Install]
WantedBy=multi-user.target