Adds SealedSecret velero-s3-credentials in namespace velero, holding
the dedicated velero-prod Hetzner Object Storage access/secret key
(scoped only to the basicstack-velero bucket).
Sealed on cp-1 via /root/seal-velero-s3-credentials.sh with the
sealed-secrets-controller public cert; plaintext never left the host.
Only the in-cluster controller can decrypt.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds the SealedSecret for the Kopia repository password that the Velero
BackupRepository will consume once the Argo app lands. Raw password is
held on cp-1 (root:root, 0600) pending the CEO handoff to Vaultwarden
and the offline safe copy (DEV-571).
Refs: DEV-569, DEV-571
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds the Open WebUI stack at openwebui.basicstack.de:
- Namespace `openwebui`, ArgoCD Application with auto-sync + selfHeal
- Own Postgres 16 + pgvector (`pgvector/pgvector:pg16`, 20Gi encrypted PVC,
fsn1-pinned); VECTOR_DB=pgvector, extension auto-created
- Own Redis 7 (2Gi encrypted PVC, fsn1-pinned) used both as cache and as
websocket manager (db=1)
- Hetzner Object Storage bucket `openwebui-basicstack` (hel1, path-style)
for file uploads; shared project S3 credentials
- Traefik ingress + Let's Encrypt TLS
- Pocket ID client `Open WebUI` (PKCE on) restricted to groups
`openwebui_admins` and `openwebui_users`; `andreas.leinen@basicstack.de`
is member of both and gets admin via OAUTH_ADMIN_ROLES
- Password login disabled (`ENABLE_LOGIN_FORM=false`, `ENABLE_SIGNUP=false`)
Co-Authored-By: Paperclip <noreply@paperclip.ing>
k3s api-server /livez on this cluster is auth-gated and returns HTTP 401
without a bearer token. The Phase E reboot poll hardcoded HTTP 200, so
every CP reboot would time out at 10 min ("api-server did not return
within 600s") even though cp-2 was actually Ready and etcd-rejoined
within ~90 s.
Any HTTP response from the api-server (200/401/403) means it is up and
serving; only network-level failures (000, connection refused) count as
"not yet back". Added --max-time 5 to keep polling snappy immediately
after the reboot when TCP may accept-then-drop.
Verified during DEV-566 cycle:
curl -sk -o/dev/null -w '%{http_code}' https://<cp>:6443/livez -> 401
on cp-1, cp-2, cp-3 (all Ready).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
k3s-worker-6 was joined on 2026-08-30 but never added to the
node_ssh_target case in update-node.sh, so the weekly cycle would die
with 'unknown node k3s-worker-6' as soon as it reached that node. Map
it to its public IP (49.12.223.133) — same pattern as worker-3/4/5.
Also add the corresponding case in os-update.sh's apt-history digest.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Stalwart's port-25 relay rejects bare EHLO labels — needs at least one
dot. Vikunja doesn't call go-mail's WithHELO, so it sends
os.Hostname() as EHLO. Setting hostname/subdomain plus
setHostnameAsFQDN=true makes the pod's kernel hostname a valid FQDN
(vikunja.basicstack.vikunja.svc.cluster.local), which Stalwart
accepts.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Vikunja now sends reminder/notification emails through
`stalwart-smtp.stalwart.svc.cluster.local:25` using unauthenticated
relay from the pod-network CIDR that Stalwart already trusts. The
`vikunja@basicstack.de` principal exists in Stalwart but its password
is unknown and admin API access to reset it is currently broken
(admin/andreas/paperclip password auth returns `{"type":"failure"}` per
`stalwart-oidc-only-admin` note). Verified end-to-end: message from
`vikunja@basicstack.de` sent via port-25 relay arrived in the passbolt
mailbox.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds the kaneo-smtp SealedSecret and points the Kaneo container at
mail.basicstack.de:587 (STARTTLS) for outbound workspace invites and
notifications. DISABLE_EMAIL_OTP_SIGN_IN stays true so sign-in remains
OIDC-only via Pocket ID.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Outline's OIDC plugin does not send `code_challenge`, so the Pocket ID
client must be created with `pkceEnabled: false` (default is `true`).
Login fails with `?notice=auth-error` and Pocket ID rejects
`/authorize` when PKCE is enforced.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Vikunja 2.x's frontend redirect helper and Go backend do not send
code_challenge. Pocket ID rejects the /authorize call with
'code_challenge parameter is missing' when the client has pkceEnabled=true.
The Vikunja client is confidential (isPublic=false) so client_secret still
authenticates the token exchange — turning PKCE off is safe and required.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Add `vikunja_scope` to the OIDC scope list the Vikunja app requests
from Pocket ID, so the `vikunja_groups` custom claim is available for
Vikunja's automatic team provisioning.
The claim itself is set per Pocket ID user group (`vikunja_admins`,
`vikunja_members`) via `PUT /api/custom-claims/user-group/:id`.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Vikunja 2.6.0 deprecates `service.jwtsecret` in favor of `service.secret`;
switch the env var name (secret payload unchanged) and fix the admin-promotion
example in the README to the current CLI (`user set-admin --admin`).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Namespace-scoped Postgres 16 + unified Vikunja container behind Traefik
ingress at vikunja.basicstack.de. Both pods pinned to Hetzner fsn1 due to
their encrypted PVCs (10 Gi DB, 10 Gi attachments). Local auth disabled;
sign-in only via Pocket ID (client `Vikunja`, groups `vikunja_admins` /
`vikunja_members`). SMTP mailer left disabled pending mailbox provisioning.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Hand-rolled manifests under apps/kaneo/ + Argo Application:
- Dedicated PostgreSQL 16-alpine on hcloud-volumes-encrypted (20Gi),
pinned to fsn1 via csi.hetzner.cloud/location node affinity.
- Kaneo v2.22.0 (single ghcr.io/usekaneo/kaneo image; API + web on
5173). Pinned to fsn1 for consistency with the volume.
- Traefik ingress at kaneo.basicstack.de with letsencrypt-prod cert.
- Better Auth JWT secret and Postgres credentials as SealedSecrets.
- OIDC via Pocket ID using CUSTOM_OAUTH_*, restricted to the two
new Pocket ID groups kaneo-admins / kaneo-members (client id
d58ff9bb-dc98-49b4-9535-4af2aba66de2).
- Registration + password + email-OTP sign-in disabled; SSO only.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- New namespace grist with in-namespace PostgreSQL 16
- Ingress grist.basicstack.de + letsencrypt-prod (DNS-01 via Hetzner)
- Pocket ID OIDC login (client "Grist", group "grist")
- All PVCs on hcloud-volumes-encrypted
- ArgoCD application via apps/app-grist.yaml
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The scheduled backup pod could not start because worker-3 (where the
opencloud data PVC is attached and where podAffinity pins the backup)
had ~40Mi of memory left by requests after opencloud/tika were
co-scheduled. rclone actually uses well under 100Mi during the sync.
Drop cpu request 100m -> 50m and memory request 256Mi -> 64Mi. Limits
unchanged (500m / 512Mi) so it can still burst.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
- provision-k3s-worker.sh now sets --node-ip=<private-ip> on the agent
install so k3s does not default to the public IP (DEV-224 root cause)
- K3S_OPERATIONS.md node table reflects the current cluster (cp-2/cp-3,
worker-4/5/6), documents the Hetzner Cloud Volume datacenter constraint,
and calls out the --node-ip requirement in the manual join steps
Co-Authored-By: Paperclip <noreply@paperclip.ing>
os-update.sh now walks all three CPs after workers, one at a time, etcd
followers first and leader last. Each CP is delegated to update-cp-node.sh
--run with ASSUME_YES=1; a health gate (cluster-health.sh +
etcdctl endpoint status) runs between CPs. Adds --skip-cps and --only-cps
flags; --only <node> and --start-from <node> now route to the correct list
automatically.
OS_UPDATE_PROCEDURE.md updated: CPs are no longer excluded, §8 renamed to
"Control plane handling" and describes the leader-last cycle + delegation.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Mirrored docker.io/restic/restic:0.19.1 to
harbor.basicstack.de/library/restic:0.19.1 (crane in-cluster Job).
Updated all CronJob pins and the restore-drill/tag-bump docs.
Digest: sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The 7.2.0 -> 7.4.0 rollout stalled because the default RollingUpdate
tried to start the new pod before terminating the old one, but they
both mount opencloud-data (RWO), causing Multi-Attach errors.
Switch to strategy: Recreate so the old pod is terminated first and
the volume detaches cleanly before the new pod starts. Same fix
pattern as bookstack (DEV-528) and other RWO-backed apps.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Skimmed 7.3.0 (2026-07-14) and 7.4.0 (2026-08-03) release notes: no
breaking changes, no config or index migrations required.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Minor version bump of the cluster OIDC provider. No breaking changes;
multi-client-secret support in 2.14 is additive; SameSite=Lax cookie
default in 2.13 is standard. DB snapshot captured pre-rollout.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Patch bump per DEV-520 plan. Only pangolin uses postgres:17.5*;
other apps (paperclip, directus, forgejo, stalwart) are on
postgres:16-alpine and are out of scope for this ticket.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
The Job self-deletes after ttlSecondsAfterFinished=3600, causing ArgoCD
to report the app permanently OutOfSync. The job body only hits
/healthz/live and prints a manual TODO; it never modified Stalwart
config. The real IP allowlist is already declared in
apps/stalwart/stalwart-config.yaml under
[server.listener.*.security] allowed-ips.
Also removes the orphan ServiceAccount/Role/RoleBinding
stalwart-config-access bundled in the same file.
Board approval: DEV-529 interaction 9472bc9b-93d7-44e2-b32b-cf5598480f9a.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
BookStack is a single-replica Deployment backed by a ReadWriteOnce
PVC (`bookstack-config`). The default RollingUpdate strategy creates
the new pod before terminating the old one, and the two pods cannot
share the RWO volume — so every rollout deadlocks on
`Multi-Attach error for volume`.
Switching to `strategy: Recreate` terminates the old pod first,
releases the PVC, and then starts the new pod. Brief downtime is
expected on rollout, but rollouts actually complete.
Discovered while rolling out the `/status` probe fix (previous commit).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
BookStack's `/` returns 302 → /login, which caused Kubernetes to
raise ProbeWarning events on the readiness (and liveness) probes.
`/status` is BookStack's built-in monitoring endpoint: it returns
200 without a redirect and is designed for health checks.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Argo CD only substitutes $secret:key references from Secrets carrying
the label app.kubernetes.io/part-of: argocd. Without it, the server
logs "secret key does not exist in secret" and renders placeholders
verbatim into the OIDC redirect URL.
Adding the label to the SealedSecret template ensures sealed-secrets-
controller re-produces the Secret with the label on every restore, so
OIDC keeps working after DR / re-seal.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Post-migration verify uncovered that Pocket ID OIDC login was broken:
the argocd-server was rendering $oidc.pocketid.clientId literally into
the authorize URL instead of substituting the client id.
The Helm chart's default $key syntax looks in argocd-secret, but our
OIDC keys are held only in the SealedSecret-backed argocd-oidc-secret
Opaque secret. Pre-migration argocd-secret happened to contain byte-for-
byte copies of those keys (legacy install), which is why it worked.
Switch to Argo CD's $secret:key form so the values point at the correct
secret without duplicating sealed material into argocd-secret.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Argo CD server was returning HTTP 307 redirect loop behind Traefik
because it was serving HTTPS on port 8080 while the ingress terminates
TLS. Setting server.insecure so the server speaks plain HTTP internally.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Argo CD repo-server needs the forgejo.forgejo.svc.cluster.local host key
in its known_hosts to clone the git repos over SSH. Adding it to
configs.ssh.extraHosts so the helm-managed ssh-known-hosts-cm renders
the entry from git and does not drift when the argocd Application syncs.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Replace the vendored ~33k-line apps/argocd/argocd-install.yaml with the
argoproj/argo-helm chart argo-cd 10.4.0 (app v3.5.1). Values live in
apps/argocd/values.yaml; the local kustomize wrapper now only carries the
Traefik ingress and the sealed secrets. The root apps/app-argocd.yaml
Application becomes multi-source (chart + this repo as $values), enables
ServerSideApply + ApplyOutOfSyncOnly, and pins the resources-finalizer
explicitly.
Behavior-equivalent to the previous install: same URL, OIDC (Pocket ID),
argo_admins RBAC mapping, resource.exclusions list, and per-component
memory limits (DEV-281). Ingress is disabled in the chart; ours stays in
kustomize with cert-manager letsencrypt-prod annotations.
README.md updated with the Helm bump procedure. argocd-install.yaml
removed.
Verified locally:
helm template argocd argo/argo-cd --version 10.4.0 \
-f apps/argocd/values.yaml -n argocd
# renders 34k lines, image: quay.io/argoproj/argocd:v3.5.1
kustomize build apps/argocd/
# renders 1 Ingress + 3 SealedSecrets, no errors
Refs: DEV-521, plan DEV-519 §3, §8.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Replaces single-CP CP1_UPDATE_PROCEDURE.md + update-cp-1.sh with an
HA-aware procedure that covers all three CPs. Rationale: after DEV-510
the cluster runs embedded etcd 3.6.12 across cp-1/cp-2/cp-3 and CPs
are NoSchedule-tainted, so:
- Kine cascade is no longer the driving risk; embedded 3-node etcd
tolerates one member down. The kubectl-latency guardrail stays as a
soft signal.
- CPs host no StatefulSets or single-replica Deployments. The old
Phase C batched stateful eviction dance is obsolete.
- CP reboots no longer lose the api-server; two peers keep serving.
/livez is polled from the operator machine and cross-checked with
etcdctl endpoint status from a peer CP.
New CP ordering rule: one CP at a time, leader last, health gate
between CPs. Refuses to start if another CP is already cordoned.
Automation:
- New scripts/os-update/update-cp-node.sh <node> is the entry point.
Supports --add-swap / --preflight / --drain / --apt / --reboot /
--finalize / --run / --dry-run. Phase A also installs etcd-client
on cp-2/cp-3 so the etcd-quorum probes work when cp-1 is later the
target. Peer picker prefers a CP that has etcdctl; refuses cp-1
target with a targeted error if no other CP has etcdctl yet.
- scripts/os-update/update-cp-1.sh is now a thin wrapper that forwards
to update-cp-node.sh k3s-cp-1 "$@" for backwards compat.
- CP1_UPDATE_PROCEDURE.md is a redirect stub pointing at the new doc.
- OS_UPDATE_PROCEDURE.md §8 topology block, kine cascade section, and
automation entry point list all point at the new HA-aware artifacts.
Verified: bash -n clean on both scripts; --dry-run walks all six
phases for each of cp-1/cp-2/cp-3; --preflight against live cluster
correctly aborts cp-1 with "install etcdctl on another CP first" and
cp-2/cp-3 with "run --add-swap first" (their current state).
Execution is board-approval-gated; DEV-497 (cp-1-only executor) is
superseded by a successor ticket.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Since DEV-510 landed HA control plane (2026-08-22), the cluster runs cp-1
plus cp-2/cp-3. The previous exclusion in os-update.sh matched `k3s-cp-1`
by hard-coded name only, which would have caused cp-2 and cp-3 to be
treated as regular fsn1 workers and drained/rebooted without the
CP-specific procedure.
Fix: select the CP list from `kubectl get nodes -l
node-role.kubernetes.io/control-plane` and skip any of those nodes. This
covers all present and future CPs automatically.
Also updated OS_UPDATE_PROCEDURE.md topology table and order rule to
document that all three CPs exist and are excluded from the weekly
cycle. The HA-aware CP OS-update procedure is a separate follow-up.
Verified on the current cluster:
[plan] EXCLUDING control-plane nodes: k3s-cp-1 k3s-cp-2 k3s-cp-3
[plan] ordered nodes (6): k3s-worker-4 k3s-update-runner k3s-worker-1
k3s-worker-2 k3s-worker-3 k3s-worker-5
Co-Authored-By: Paperclip <noreply@paperclip.ing>
New freshness (BackupForgejoStale, 28h) and integrity
(BackupForgejoCheckFailed) rules matching the sibling
loki/grafana/k8s-resources/prometheus alerts.
Metrics come from backup_forgejo_{timestamp_seconds,check_status,success}
emitted by apps/forgejo/forgejo-backup-cronjob.yaml.
Co-Authored-By: Paperclip <noreply@paperclip.ing>