Compare commits

...
31 Commits
Author SHA1 Message Date
Scooby Husky 779786262a Fix VPS GitLab + Nextcloud crash loops found during health sweep
- infrastructure/vps-standby/gitlab/manifests/ha-postgres-app-externalsecret.yaml:
  creationPolicy Merge -> Owner (already applied live, committing to
  match). Merge assumed CNPG creates a base pg-gitlab-app/pg-praefect-app
  secret to merge a password into - wrong for these VPS Clusters since
  Phase 1c's replica-cluster recreate; CNPG doesn't create a local
  owner secret for a replicated role. Left pg-gitlab-app/pg-praefect-app
  missing entirely, crash-looping vps-gitlab-webservice on
  ActiveRecord::DatabaseConnectionError for ~39h.

- infrastructure/vps-standby/nextcloud/manifests/pvc-restore-cronjob.yaml:
  exclude config.php from the restic restore. The job's own comment
  assumed nextcloud runs at replicas: 0 on the VPS; live confirms
  replicas: 1 (no such key was ever actually set) - restic couldn't
  overwrite the live pod's config.php (permission denied), failing the
  whole job every run for 2+ days even though everything else restored
  fine. Excluding it is correct regardless of permissions - a live
  pod's own config.php shouldn't be overwritten by a background
  restore job.

Also fixed live (not git-tracked, config.php is PVC-persisted runtime
state, not sourced from git):
- home + VPS nextcloud config.php dbpassword: out of sync with CNPG's
  actual current pg-nextcloud-app password (baked in once at install,
  never re-synced). VPS's case was two-layered - its own
  pg-nextcloud-app secret also didn't match the real Postgres role
  password, since VPS's pg-nextcloud is a read-only streaming replica
  (spec.replica.enabled) and the authoritative password lives on home.
- grafana: broke a RollingUpdate deadlock (single-replica Deployment +
  RWO Ceph volume - new pod couldn't start while the old pod still
  held the only-one-node-at-a-time attachment, and the rollout
  wouldn't scale down the old pod until the new one was ready).
- suspended the stale gitlab-mirror-sync CronJob on the VPS (spamming
  auth failures every ~15min since its stored token predates the
  Postgres-replication cutover of VPS GitLab's DB) rather than
  deleting it, per the plan's own note to defer that until Phase 2b is
  verified.
2026-08-22 15:54:05 -05:00
Scooby Husky 9a9eb207b5 Revert workhorse TLS: broke public site on shared port 8181
global.workhorse.tls.enabled replaced workhorse's plaintext listener
with a TLS-only one on port 8181 - but that port isn't internal-API
-only, it's also what gitlab-web HTTPRoute routes ALL public
gitlab.kube.huskypup.net traffic to. Envoy terminates public HTTPS
and proxies to the pod in plaintext, so the TLS-only listener 400'd
every public request ("Client sent an HTTP request to an HTTPS
server"). Restored live via a direct ConfigMap patch +
rollout restart before this commit could even be pushed, since the
outage took out this repo's own GitLab-hosted git access too.

global.workhorse.host stays (harmless without tls.enabled). Securing
the VPS's future internal-API-over-WAN hop needs a different
mechanism that doesn't collide with the public listener - not solved
here.
2026-08-22 14:55:40 -05:00
Scooby HuskyandClaude Sonnet 5 1febd18bd6 Widen letsencrypt-production DNS-01 solver to cover ha.huskypup.net
GitLab's new Gitaly/workhorse TLS certs are the first thing needing a
real Let's Encrypt cert under ha.huskypup.net (the cross-site floating-
hostname zone) - everything else there (Postgres) uses CNPG's own
self-signed certs, never hit this gap. Confirmed live: DNS-01 order
failed with 'no configured challenge solvers can be used for this
challenge' until this zone was added to the selector.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 14:25:36 -05:00
Scooby HuskyandClaude Sonnet 5 bce3861083 GitLab cross-site replication Phase 2b: enable Gitaly + workhorse TLS
Per the approved plan (Istio federation scoped separately, current
NodePort/PeerAuth/cert-manager pattern continues for these 2 remaining
links). Real Let's Encrypt certs (not self-signed) via the same DNS-01
ClusterIssuer used everywhere else - both sites already trust Let's
Encrypt's public CA, no custom CA distribution needed.

- global.gitaly.tls.enabled: adds tls_listen_addr (8076) alongside the
  existing plaintext listener (8075, stays for in-cluster traffic
  that doesn't need it). Requires
  global.praefect.virtualStorages[0].tlsSecretName (chart's own NOTES.txt
  validation rejects the render without it, confirmed live).
- global.workhorse.tls.enabled + global.workhorse.host: Gitaly's [gitlab]
  url (internal-API callback for gitlab-shell hooks) switches from an
  unpublishable *.svc.cluster.local name to a real floating hostname
  (gitlab-internal-api.ha.huskypup.net) that Let's Encrypt CAN certify -
  confirmed live this replaces the plaintext [[listeners]] block with
  [listeners.tls] on the SAME port (8181), not a second port like Gitaly.
- praefect-ha-configmap.yaml (the manually-patched live config) updated
  to tls:// + floating hostnames for all 3 home nodes, so home's own
  Praefect uses the identical addresses it'll hand the VPS as peer
  addresses once Phase 2b's 4th node lands.
- New CoreDNS rewrite for gitlab-internal-api.ha.huskypup.net (same
  local-rewrite pattern as pg-*/gitaly-* already have).

Sequencing note: applying live - Certificates must issue before the
TLS-enabling values land, or Gitaly/webservice pods fail to start
(missing secret for their init container's cert copy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 14:24:13 -05:00
Scooby HuskyandClaude Sonnet 5 166ff0ffb7 GitLab cross-site replication Phase 2b: home-side Gitaly network exposure
Per-pod NodePort Services for the 3 existing Gitaly nodes
(ha-gitaly-nodeport.yaml) plus home's Rails internal API
(ha-gitlab-internal-api-nodeport.yaml, needed for gitlab-shell hooks
whenever the VPS node is primary for a repo). PeerAuthentication
PERMISSIVE + AuthorizationPolicy port rules for both, matching the
proven Postgres pattern. CoreDNS rewrites so home's own Praefect
resolves the floating hostnames locally instead of round-tripping.

Reached over the Netbird mesh, not the UniFi WAN forward used for
Postgres - Gitaly gRPC here is plaintext (carries the gitaly_token,
gitlab_shell_secret, and raw repo contents), unlike CNPG's
TLS-negotiated stream.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:59:03 -05:00
Scooby HuskyandClaude Sonnet 5 e73561e5ca Phase 2a verification: confirm git operations still work post-ConfigMap-patch
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:57:16 -05:00
Scooby HuskyandClaude Sonnet 5 6cf1d89278 GitLab cross-site replication Phase 2a (corrected): ignoreDifferences approach
The CONFIG_TEMPLATE_DIRECTORY redirect from the previous commit doesn't
work - confirmed live that extraVolumes is a dead values key for the
Praefect subchart specifically (its statefulset.yaml never calls the
gitlab.extraVolumes helper in its volumes: list, only volumeMounts
calls the corresponding helper - a real chart limitation, not a config
mistake). A dangling volumeMount with no matching volume would have
failed to schedule.

Real fix: ignoreDifferences on ConfigMap gitlab-praefect's data field
(argocd-apps/apps/gitlab.yaml) lets Helm create the object normally
while ArgoCD stops reconciling its content afterward - the actual
config gets kubectl-patched onto the live object directly.
praefect-ha-configmap.yaml is now a git-tracked reference/documentation
copy (deployed under its own harmless name) rather than something
Helm/ArgoCD wires in on its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:54:43 -05:00
Scooby HuskyandClaude Sonnet 5 a309495e1e Fix YAML syntax error from previous commit (leftover maxUnavailable line)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:49:18 -05:00
Scooby HuskyandClaude Sonnet 5 38dad209e3 GitLab cross-site replication Phase 2a: Praefect config override mechanism
Proves the CONFIG_TEMPLATE_DIRECTORY redirect works before Phase 2b
adds any actual cross-site dependency. Content is byte-identical to
the chart's current rendering (confirmed live) - this commit only
tests the override plumbing itself: a new ConfigMap
(praefect-ha-configmap.yaml) mounted at a different path than the
chart's own /etc/gitaly/templates (can't reuse that path/volume name -
Kubernetes rejects duplicates), with CONFIG_TEMPLATE_DIRECTORY
overridden via extraEnv to point at it instead (last-wins env
semantics, confirmed Kubernetes-documented behavior).

Also removes gitlab.praefect.virtualStorages - confirmed live dead
config, global.praefect.virtualStorages is what the chart's template
actually reads.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:48:35 -05:00
Scooby HuskyandClaude Sonnet 5 4a0de188df GitLab cross-site replication Phase 1c (stage 2): live streaming replication
Adds externalClusters connectionParameters + replica.self/primary/source
to all four Cluster CRs (home pg-gitlab/pg-praefect, VPS
pg-gitlab/pg-praefect), turning last commit's recovery-only replicas
into real CNPG streaming replicas - exact pattern already proven for
pg-authentik. TLS certs copied cross-cluster manually (kubectl, not
git, same as every other cross-cluster secret in this plan).

Also adds pg-gitlab-app/pg-praefect-app password sync (PushSecret home
-> Vault -> ExternalSecret VPS, Merge policy) - CNPG-generated
passwords stay in sync via WAL replication but the K8s Secret OBJECTS
never resync on their own, same gap already closed for pg-authentik-app.

VPS's standalone clusters were already destroyed and recreated as
recovery-mode replicas in the prior commit (with the user's explicit
help running the classifier-blocked kubectl delete) - this commit turns
on the actual streaming replication on top of that.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:31:09 -05:00
Scooby HuskyandClaude Sonnet 5 83c06a888d GitLab cross-site replication Phase 1c (stage 1): VPS clusters as real replicas
Replaces VPS's independent standalone pg-gitlab (own initdb, own
root/Scooby test data - never meant to survive this, per the approved
plan) with a bootstrap.recovery replica of home's, matching the
pg-authentik precedent. New pg-praefect cluster on the VPS too (never
existed before - VPS ran Gitaly without Praefect).

Stage 1 of 2: recovery-only, no streaming replication config yet - that
needs this cluster's own CNPG-generated certs to exist first, which
only happens once CNPG actually creates the cluster. Stage 2 (next
commit) copies certs cross-cluster and adds externalClusters
connectionParameters + replica.self/primary/source to all four Cluster
CRs (home pg-gitlab/pg-praefect, VPS pg-gitlab/pg-praefect).

postgresql.parameters copied verbatim from home's manifests (must
match-or-exceed, confirmed live for pg-authentik). vps-minio-secret
created manually on the VPS (kubectl, not git - same as every other
cross-cluster secret in this plan) pointing at the VPS's own local
MinIO, which now holds home's pg-gitlab/pg-praefect base backups
(triggered manually via Backup CRs since neither had run yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:19:43 -05:00
Scooby HuskyandClaude Sonnet 5 ca94282552 GitLab cross-site replication Phase 1b: secret material sync
PushSecret (home) + ExternalSecret (VPS) pairs for the four
chart-generated secrets whose values must match before Phase 1c's
Postgres replication goes live: gitlab-rails-secret (secret_key_base/
db_key_base/otp_key_base/openid_connect_signing_key, all in one
secrets.yml key - confirmed live, not four separate keys as first
assumed), gitlab-gitlab-shell-secret, gitlab-gitaly-secret,
gitlab-praefect-secret (the latter two also needed for Phase 2's
cross-site gRPC auth). Same pattern as
infrastructure/authentik/manifests/ha-postgres-app-pushsecret.yaml.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 13:08:22 -05:00
Scooby HuskyandClaude Sonnet 5 04fa80ece3 GitLab cross-site replication Phase 1a: Postgres network plumbing
Per the approved plan (jiggly-snacking-iverson.md, 'GitLab True
Cross-Site Replication' section) - replacing the git-mirror CronJob
with real CNPG streaming replication for pg-gitlab and pg-praefect,
reusing the exact pattern already proven for pg-authentik.

This commit is network plumbing only, no DB replication yet:
- Repoint both CNPG clusters' barmanObjectStore backup target from
  in-cluster gitlab-minio-svc to VPS MinIO (also becomes the initial
  seed source for the VPS's replicas in Phase 1c).
- New ha-gitlab-postgres/ha-praefect-postgres NodePort Services
  (bypass CNPG's own -rw Services) on both home and VPS.
- New PeerAuthentication with portLevelMtls PERMISSIVE on the replication
  ports - ambient mesh STRICT default would otherwise reset the
  VPS/witness's connection before the Postgres TLS handshake starts.
- Extended allow-gitlab-access AuthorizationPolicy with a port-scoped
  rule for the same traffic (no source.namespaces rule can match
  external, non-mesh peers).
- CoreDNS rewrites for pg-gitlab.ha.huskypup.net / pg-praefect.ha.huskypup.net
  so home's own pods reach the floating hostname locally instead of
  round-tripping through the WAN forward (no NAT hairpin support).

Still needed before Phase 1b/1c: UniFi WAN port-forwards for external
ports 61442/61443 -> talos-cp-01:32442/32443, source-restricted to the
VPS + witness public IPs (manual, same as the existing 61432 rule).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 12:47:03 -05:00
Scooby HuskyandClaude Sonnet 5 a104008111 Add VPS GitLab mirror-sync CronJob for Homelabv4 repo
GitLab's native pull-mirror feature is Premium/Ultimate-gated on this
chart/version - confirmed live: the project API rejects 'mirror' and
'import_url' as unrecognized params on this CE instance entirely.
Plain git equivalent instead: CronJob every 15min does
'git clone --mirror' from home's Scooby/Homelabv4 (read-only deploy
token) then 'git push --mirror' to the VPS copy (write_repository
PAT, since deploy tokens can't push - not a valid scope for them
either). Matches the original plan's 'Gitea pull mirror of the GitOps
source' intent now that real GitLab replaced Gitea. Both tokens in
Vault at secret/vps/gitlab-mirror, pulled via ExternalSecret.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:55:20 -05:00
Scooby HuskyandClaude Sonnet 5 d99b560cec Loosen VPS GitLab sidekiq liveness probe further (~17min grace)
3 consecutive attempts with the first loosening (~8min grace) all made
genuine progress (booted, started listening on queues) but never got
to open the health-check port before being killed - box's sustained
background load (gitea, restic, barman, k3s - unrelated to GitLab) is
apparently enough to starve that specific thread. More patience, not
a different fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:39:49 -05:00
Scooby HuskyandClaude Sonnet 5 a9039e0723 Set Recreate deployment strategy for VPS GitLab webservice/sidekiq
RollingUpdate's default surge kept creating a second pod during every
rollout even with 1 replica - on this CPU-overcommitted single-node
VPS that meant 2 concurrent Ruby boots starving each other, never
converging (load avg hit 39+ on 6 cores). Recreate guarantees only 1
pod exists at a time. No availability cost given minReplicas=maxReplicas=1
already.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:16:56 -05:00
Scooby HuskyandClaude Sonnet 5 1ab292e185 Fix GitLab Authentik SSO: restore providers list, populate real provider key
GITLAB_OMNIBUS_CONFIG (gitlab_rails['omniauth_providers'] = [...]) is
an omnibus-Docker-image-only convention. This chart's CNG webservice
image never processes it - confirmed via gitlab/charts/gitlab/
templates/_omniauth.tpl, which only reads
global.appConfig.omniauth.providers (a list of {secret,key} refs, each
pointing at a Secret key holding a whole YAML-encoded provider block
loaded via Ruby's YAML.load_file). So GITLAB_OMNIBUS_CONFIG was always
a silent no-op on both home and VPS - neither ever actually had SSO
configured despite gitlab-oidc-secret existing and looking correct.

Yesterday's fix for 'FailedMount: references non-existent secret key:
provider' removed the providers: list entirely instead of populating
that key - stopped the crash, but also silently deleted the only real
OIDC config path on both sites (no error, login page just lost its SSO
button). This restores providers: on both, and gitlab-oidc-secret's
ExternalSecret template now actually renders a provider key containing
a real YAML provider block (with the templated client_id/secret
substituted in), matching what _omniauth.tpl expects.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:14:25 -05:00
Scooby HuskyandClaude Sonnet 5 59bff07a53 Loosen VPS GitLab webservice/sidekiq liveness probes, cut worker count
VPS box is chronically CPU-overcommitted (load avg ~22-26 on 6 cores,
confirmed live - unrelated pre-existing services: barman, k3s itself,
node/gunicorn apps). Chart-default liveness probes (~200s grace) were
SIGTERM-killing both webservice and sidekiq mid-boot (still inside
bootsnap's require), every restart resetting progress to zero and
never actually finishing. Loosened to a ~7min grace window and dropped
webservice to 1 worker process instead of 2, less to fork/boot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 23:10:48 -05:00
Scooby HuskyandClaude Sonnet 5 ab084715e6 Fix VPS GitLab ingress backend service name
Ingress pointed at 'gitlab-webservice-default' but the chart release
name on the VPS is 'vps-gitlab', so the actual Service Helm creates is
'vps-gitlab-webservice-default' (confirmed via 'kubectl get svc -n
gitlab'). Home's GitLab isn't affected - it routes via Istio
VirtualService, not this Ingress, and its release name has no prefix.

This was the actual cause of the persistent 404 - webservice pod was
healthy and passing readiness checks the whole time, Traefik just had
no matching backend to route to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:47:48 -05:00
Scooby HuskyandClaude Sonnet 5 989d42dd50 Fix GitLab OIDC secret FailedMount blocking webservice/sidekiq startup
Real bug affecting both home and VPS GitLab, found live diagnosing why
VPS's webservice pod was stuck at Init:0/3 for 74+ minutes (surfacing as
404s - nothing was ever actually serving):

global.appConfig.omniauth.providers: [{secret: gitlab-oidc-secret, key:
provider}] requires that secret to have a 'provider' key (a full YAML
omniauth provider config) - gitlab-oidc-secret never has one, only
GITLAB_OIDC_CLIENT_ID/SECRET, which get consumed via extraEnvFrom +
GITLAB_OMNIBUS_CONFIG's gitlab_rails['omniauth_providers'] instead (the
actual, complete mechanism already configuring OIDC - this providers:
list was always redundant). Removed it from both values.yaml files.

Home's GitLab has run fine so far because Kubernetes doesn't re-validate
already-mounted volumes when a referenced Secret's shape changes -
webservice/sidekiq there just haven't restarted since gitlab-oidc-secret
was last (if ever) in a shape with that key. VPS's identical values
shape hit it immediately on first pod creation. This was a live landmine
for home too - fixed before it could bite on a future restart.

Also: global.kas.enabled: false added to the VPS values (gitlab.kas.enabled
alone doesn't stop KAS pods - found live, separate toggle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 22:36:16 -05:00
Scooby HuskyandClaude Sonnet 5 6a335132fe VPS GitLab: disable upgradeCheck hook (breaks fresh installs via ArgoCD)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:18:13 -05:00
Scooby HuskyandClaude Sonnet 5 5670a941d4 Deploy real GitLab on the VPS (not just Gitea)
User wants actual GitLab on the VPS, not just Gitea's pull-mirror -
deliberate scope increase from the original plan's 'keep the VPS
lighter' design for git hosting specifically.

Simplified single-node equivalent of apps/gitlab (home) - same chart
version (7.7.0), same Authentik OIDC provider/client_id (added a second
redirect_uri to the shared GitLab provider in infrastructure/authentik/
gitlab-blueprint.yaml), same known chart bugs already diagnosed at home
(redis-init-fix.yaml for the initializer-ordering bug) - but:
  - 1 CNPG instance (local-path) instead of home's 3-instance HA
  - No Praefect - pointless HA routing with a single Gitaly node
  - No PgBouncer - direct CNPG connection, traffic is low enough
  - No SAML, no Container Registry, no KAS - addable later if needed
  - Own Traefik Ingress + https-redirect Middleware from the start
    (found needed the hard way on Vault/Gitea/n8n/Nextcloud/Authentik
    earlier this session - applying that lesson here immediately)

New Vault paths this needed: secret/vps/gitlab-redis (freshly generated,
this Redis instance is new, nothing to reuse) and widened the
vps-eso-reader policy to also allow secret/gitlab-oauth (the shared
Authentik client, already used by home's GitLab).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:04:48 -05:00
Scooby HuskyandClaude Sonnet 5 220cdfb00a VPS-standby: force HTTPS redirect on all 5 Traefik ingresses
Root cause of the Vault OIDC 'Missing auth_url' error (and the same
symptom on every other VPS-standby app): the Ingress tls: block makes
HTTPS available but Traefik still serves plain HTTP on the same host too
- nothing was forcing the redirect. A browser landing on
http://vault.vps.huskypup.net gets a Vault UI that computes its OIDC
callback using window.location.origin (http://...), which doesn't match
the https:// entry in allowed_redirect_uris - Vault silently returns an
empty auth_url rather than an obviously-config-looking error. Confirmed
via a HAR capture of the actual failing browser request.

Adds a per-namespace Traefik Middleware (redirectScheme -> https,
permanent) referenced via router.middlewares on each Ingress. Applied
directly via kubectl first to test - ArgoCD's selfHeal immediately
reverted it since it wasn't in git yet, confirming the fix needs to ship
through the normal pipeline rather than live kubectl edits on ArgoCD-
managed VPS resources.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:58:40 -05:00
Scooby HuskyandClaude Sonnet 5 bb749b32b9 Fix VPS Vault OIDC login: missing redirect_uri + missing DNS
Two pre-existing gaps (not caused by this session's earlier work),
surfaced when the user started testing the VPS's standby Vault
(infrastructure/vps-standby/vault/) login via Authentik SSO:

1. VPS Vault's own auth/oidc/role/default allowed_redirect_uris only had
   vault.kube.huskypup.net (home) and localhost:8250 - never had
   vault.vps.huskypup.net registered, even though VPS's Vault shares the
   same Authentik OAuth2 provider/client_id as home's. Fixed on both
   sides: the shared blueprint (redirect_uris list) and VPS Vault's own
   role config directly (separate Vault instance/backend, needed its own
   write).

2. auth.kube.huskypup.net (Authentik itself) had NO public DNS record at
   all - it only ever resolved via UniFi's local DNS for home-LAN
   clients. Vault's OIDC backend runs server-side on the VPS and needs to
   fetch Authentik's discovery document directly - it silently returned
   an empty auth_url because it couldn't resolve the hostname at all
   ('missing auth_url' in Vault's UI, root cause only found by checking
   DNS resolution from inside the VPS's own vault pod). Added a public
   Cloudflare A record, same pattern as vault.kube.huskypup.net and
   pg-authentik.ha.huskypup.net earlier - user confirmed before adding,
   given it's Authentik itself. This likely also fixes OIDC for any other
   VPS-hosted app doing SSO against home's Authentik, not just Vault.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:33:22 -05:00
Scooby HuskyandClaude Sonnet 5 d9a4c16481 Move all 8 Authentik OIDC client_secrets out of plaintext git
Completes the 'make the repo proper' cleanup from earlier this session -
these were flagged but deliberately not touched in 185e9c2 given the
blast radius (live SSO for 8 apps). User confirmed: fix all 8 now.

infrastructure/authentik/{argocd,gitlab,nextcloud,grafana,n8n,guacamole,
rancher,vault}-blueprint.yaml: converted from plain ConfigMap (client_secret
hardcoded) to ExternalSecret with a templated blueprint body
(client_secret: "{{ .clientSecret }}") pulling from Vault. Chart already
supports mounting blueprints from Secrets (blueprints.secrets, alongside
blueprints.configMaps) - infrastructure/authentik/values.yaml updated to
route these 8 there instead.

For argocd/nextcloud/n8n/guacamole/rancher: Vault already had the matching
value at secret/<app>-oauth (the APP side was already Vault-backed via its
own ExternalSecret) - the blueprint was the only remaining plaintext copy.

For gitlab/grafana/vault: Vault had no copy at all yet - created
secret/{gitlab,grafana,vault}-oauth with the EXISTING live values (not
rotated - these are the actual working credentials right now, rotating
would break login until every consumer is updated in lockstep, which is
out of scope for a cleanup pass). Also fixed the OTHER plaintext copies
that existed for these three specifically:
  - apps/gitlab/manifests/external-secret-oidc.yaml (new): replaces a
    manually kubectl-created, never-git-tracked gitlab-oidc-secret.
  - infrastructure/grafana/manifests/grafana-oauth-secret.yaml: was a
    plain Secret whose own comment said 'hardcoded from blueprint'.
  - infrastructure/vault/manifests/vault-init-{configmap,job}.yaml: this
    one COULDN'T be converted to the same ExternalSecret-from-Vault
    pattern - it's the PostSync Job that grants ESO's own Kubernetes-auth
    role in Vault, so ESO can't yet authenticate to pull anything from
    Vault at the point this script runs (genuinely circular). Sourced
    from a new vault-oidc-bootstrap Secret instead - created once
    manually (kubectl, not git, matching how Vault's own root/unseal
    material is already handled), independent of the ESO pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:04:22 -05:00
Scooby HuskyandClaude Sonnet 5 c6d4294be6 Widen mTLS PERMISSIVE/AuthorizationPolicy to cover port 61432 too
home's own authentik pods reach ha-authentik-postgres via the CoreDNS
rewrite on port 61432 (not just external traffic via NodePort on 5432/
61432) - discovered live that Istio ambient's port-level mTLS/L4
authorization enforcement is keyed on the port actually dialed (61432
here), not just the pod's real containerPort (5432) traffic eventually
reaches after Service translation. The existing port-5432-only rules
(from the streaming-replication fix) didn't cover this in-cluster path,
surfacing as 'server closed the connection unexpectedly' from home's own
authentik-worker pod.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:51:42 -05:00
Scooby HuskyandClaude Sonnet 5 185e9c292e Fix real cause of VPS authentik's DB timeout loop + repo secrets cleanup
Root cause of the 'PostgreSQL connection failed, retrying... (connection
timeout expired)' loop that survived every earlier fix (streaming
replication, the password sync, the port fix, the CoreDNS override):
authentik's Helm values had TWO sources for AUTHENTIK_POSTGRESQL__HOST/
PORT - an explicit uppercase env: override (pointed at the floating
pg-authentik.ha.huskypup.net:61432) AND a chart-generated envFrom
secretRef (pg-authentik-app, prefix: AUTHENTIK_POSTGRESQL__) whose keys
are lowercase (host, port, ...), producing a SEPARATE
AUTHENTIK_POSTGRESQL__host/port pair pointing at the old local
pg-authentik-rw:5432. Kubernetes treats these as two unrelated env vars
(case-sensitive), but authentik's own generic AUTHENTIK_*-prefixed
env-var scanner apparently doesn't, and was resolving to the lowercase
(stale, local) values regardless of the explicit override - confirmed
live by dumping the pod's actual env: both HOST and host were present
with different values. Removed the now-fully-redundant envFrom entry
(every key it provided is already explicitly set via valueFrom).

Also: user correctly pointed out plaintext secrets don't belong in the
repo. Audited for the same class of issue as the already-fixed Cloudflare
token (infrastructure/cert-manager/manifests/secret-cf-token.yaml):
- infrastructure/external-dns/manifests/secret-external-dns-unifi.yaml
  had a live UniFi API key in plaintext - moved to Vault+ExternalSecret,
  same pattern as the Cloudflare token fix. The key itself is still the
  original (now Vault-stored) value - rotating it requires the UniFi web
  UI (no self-service API), noted in the file as a separate pending step.
- infrastructure/vps-eso/manifests/clustersecretstore.yaml had the
  AppRole's roleId inline (added this session) - moved to roleRef,
  sourced from the same Secret as secretId, consistent with 'nothing
  sensitive in git' regardless of how sensitive one field is alone.

NOT touched, flagged separately for the user: infrastructure/authentik/
*-blueprint.yaml (vault, gitlab, n8n, nextcloud, rancher, grafana, argocd,
guacamole) all have live-looking high-entropy client_secret values
hardcoded in plaintext - same class of issue but much larger blast radius
(8 apps' SSO), needs its own coordinated rotation, not bundled into this
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:47:50 -05:00
Scooby HuskyandClaude Sonnet 5 cfbe844ecd Repoint CoreDNS rewrite at ha-authentik-postgres (has port 61432)
Follow-up to 8b8549e - pg-authentik-rw only listens on 5432, the app
tier needs 61432. See that commit for the full explanation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:34:17 -05:00
Scooby HuskyandClaude Sonnet 5 8b8549e432 Add port 61432 to ha-authentik-postgres for home's local CoreDNS rewrite
Found via live test: the CoreDNS rewrite for pg-authentik.ha.huskypup.net
resolves the NAME correctly to a local service, but DNS rewriting can't
change the PORT a client then connects to - the app tier is configured
with AUTHENTIK_POSTGRESQL__PORT=61432 (matching the external floating
hostname), so the local target needs to actually listen on 61432 too, or
the connection times out even though DNS resolves fine. Also repoints the
rewrite target from CNPG's own pg-authentik-rw to this Service, so it
resolves to a port that actually exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:34:01 -05:00
Scooby HuskyandClaude Sonnet 5 da60230997 Bring kube-system/coredns under GitOps management
Directly addresses the user's point: 3 existing DNS rewrite rules (auth/
rancher/netbird.kube.huskypup.net) were only ever applied via live kubectl
edits, never git-tracked - meaning every future change (like the
pg-authentik.ha.huskypup.net rewrite this session needed) required a
manual, classifier-gated live edit to cluster-wide DNS config instead of
a normal commit ArgoCD applies on its own.

No Talos/kubeadm ownerReferences on the ConfigMap or Deployment (checked
live), so it's safe for ArgoCD to take over. No pod restart needed after
sync either - the Corefile's own 'reload' plugin hot-reloads within
~30-60s of the ConfigMap volume updating on disk.

Adds the pg-authentik.ha.huskypup.net rewrite (see the failover-watcher
commits) as part of the same change, now flowing through git like
everything else instead of the standalone script handed off previously.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:30:03 -05:00
Scooby HuskyandClaude Sonnet 5 cbf7c07711 Vault -> VPS secret pipeline: replaces manual kubectl/ssh secret copies
User correctly flagged this: manually copying secrets between home and
the VPS defeats the entire point of having Vault. Every VPS secret this
session (MinIO creds, Cloudflare token, CNPG certs, the pg-authentik-app
password) was a one-off kubectl create secret copy-paste, because the
VPS's k3s cluster had no Vault/ESO pipeline at all - this builds one.

- infrastructure/authentik/manifests/ha-postgres-app-pushsecret.yaml:
  home pushes pg-authentik-app's password into Vault at
  secret/vps/pg-authentik-app (PushSecret, not just Get - this password is
  CNPG-generated, not Vault-native, so it has to originate from a push).

- infrastructure/vps-eso/manifests/clustersecretstore.yaml: ESO on the
  VPS (installed via helm, out-of-band like k3s/cert-manager - see the
  file's own header) authenticates to home's Vault via AppRole (not
  Kubernetes auth - the VPS is a separate cluster with no federation to
  home's API server). Reachable via a new public
  https://vault.kube.huskypup.net record - Vault was deliberately kept
  off the public internet before this, explicitly confirmed with the user
  before opening it. Traffic goes through the same Istio ingress gateway
  already serving other public hosts, so unlike the ha-authentik-postgres
  NodePort case, no PeerAuthentication/AuthorizationPolicy change was
  needed - it arrives as a normal in-mesh call from the gateway's own
  identity, not raw external TCP to a pod.

  Found and fixed a real Vault gotcha while wiring this up: tried to
  remove the AppRole's token_bound_cidrs restriction (added first, before
  discovering Vault can't see the VPS's real source IP through the
  gateway - it only ever sees the gateway's own pod IP) by omitting the
  field from a follow-up  - that does NOT clear it, the
  AppRole role endpoint preserves omitted fields rather than resetting
  them to default. Had to explicitly write token_bound_cidrs=. Spent a
  while chasing a misleading 403 permission denied on auth/token/
  lookup-self before finding this - vault token capabilities said read
  was allowed (policy was fine), the actual rejection was IP-bound token
  use from an unbound context.

- infrastructure/vps-standby/authentik/manifests/
  pg-authentik-app-externalsecret.yaml: pulls it back down, Merge policy
  (only overwrites the password key - host/dbname/username stay local,
  CNPG still needs its own local -rw hostname for internal use).

- argocd-apps/vps-standby/vps-eso.yaml: new Application for the
  ClusterSecretStore.

Verified end-to-end: password now matches between home and VPS's
pg-authentik-app secrets via this pipeline (not the earlier manual patch),
confirmed by comparing both live secret values after ESO's sync.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:26:26 -05:00
58 changed files with 3172 additions and 519 deletions
+68 -5
View File
@@ -53,17 +53,25 @@ spec:
max_parallel_workers_per_gather: "2"
max_parallel_workers: "4"
# Backup configuration to MinIO
# Backup configuration - repointed to VPS MinIO 2026-08-22 (GitLab
# cross-site replication plan, see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md's "GitLab True
# Cross-Site Replication" section). Was gitlab-minio-svc (in-cluster,
# same Ceph the backup is meant to protect against). Also doubles as
# the initial-seed source for the VPS's own pg-gitlab replica
# (bootstrap.recovery, Phase 1c) - same barmanObjectStore block serves
# both purposes, matching the Authentik precedent
# (infrastructure/authentik/manifests/cnpg-cluster.yaml).
backup:
barmanObjectStore:
destinationPath: s3://gitlab-backups/pg-gitlab
endpointURL: http://gitlab-minio-svc.gitlab.svc.cluster.local:9000
destinationPath: s3://cnpg-backups/pg-gitlab
endpointURL: http://100.108.113.41:9000
s3Credentials:
accessKeyId:
name: gitlab-minio-secret
name: vps-minio-secret
key: accesskey
secretAccessKey:
name: gitlab-minio-secret
name: vps-minio-secret
key: secretkey
wal:
compression: gzip
@@ -72,3 +80,58 @@ spec:
monitoring:
enablePodMonitor: true
# GitLab cross-site replication Phase 1c stage 2 (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - streaming
# replication to/from the VPS's pg-gitlab cluster, alongside (not
# replacing) the barmanObjectStore backup above. Exact pattern proven
# for pg-authentik (infrastructure/authentik/manifests/cnpg-cluster.yaml).
#
# Auth: reuses CNPG's own generated streaming_replica client cert,
# copied here manually as pg-gitlab-vps-{ca,replication} (kubectl, not
# git). sslmode: verify-ca (not verify-full) - the VPS's server cert is
# issued for its in-cluster DNS names, not the NodePort IP dialed here.
#
# Reachable via 100.108.113.41 (VPS's Netbird IP) - home reaching OUT
# to the VPS already works via the netbird-egress DaemonSet's route, no
# UniFi port-forward needed for this direction (only the reverse,
# VPS->home, needed that - see ha-postgres-nodeport.yaml).
externalClusters:
- name: home
connectionParameters:
host: pg-gitlab-rw
port: "5432"
dbname: gitlabhq_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-gitlab-replication
key: tls.crt
sslKey:
name: pg-gitlab-replication
key: tls.key
sslRootCert:
name: pg-gitlab-ca
key: ca.crt
- name: vps
connectionParameters:
host: 100.108.113.41
port: "32444"
dbname: gitlabhq_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-gitlab-vps-replication
key: tls.crt
sslKey:
name: pg-gitlab-vps-replication
key: tls.key
sslRootCert:
name: pg-gitlab-vps-ca
key: ca.crt
# Distributed topology: both sides agree home is primary today.
replica:
self: home
primary: home
source: home # required unconditionally by the admission webhook even though home isn't replicating from anyone (self == primary)
@@ -0,0 +1,69 @@
# gitlab-oidc-secret was a manually kubectl-created plain Secret (never
# git-tracked, never rotatable, no record of what it was) - found and
# fixed 2026-08-20 alongside infrastructure/authentik/gitlab-blueprint.yaml
# (same value, same Vault path - see that file for the full story).
#
# `provider` key added 2026-08-21: found live that GITLAB_OMNIBUS_CONFIG's
# `gitlab_rails['omniauth_providers'] = [...]` (still set in this app's
# values.yaml's extraEnv, for reference/documentation only at this point)
# is an omnibus-image-only convention - the CNG webservice image this
# chart actually runs never processes it, so it was always a no-op and
# GitLab never had SSO configured despite it looking configured. The
# REAL mechanism (gitlab/charts/gitlab/templates/_omniauth.tpl in the
# chart) is global.appConfig.omniauth.providers: a list of
# {secret, key} refs, each pointing at a Secret key whose value is a
# whole YAML-encoded provider block (loaded via Ruby's YAML.load_file
# at container start, baked into gitlab.yml) - not raw client_id/secret
# strings. This is also why the `providers:` list got removed entirely
# a day earlier chasing a "FailedMount: references non-existent secret
# key: provider" error: the fix should have been to populate that key
# correctly (this), not remove the reference to it - doing so silently
# killed SSO on both home and VPS GitLab (no error, the login page just
# had no SSO button).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-oidc
namespace: gitlab
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: gitlab-oidc-secret
creationPolicy: Owner
template:
type: Opaque
data:
GITLAB_OIDC_CLIENT_ID: "{{ .clientId }}"
GITLAB_OIDC_CLIENT_SECRET: "{{ .clientSecret }}"
provider: |
name: openid_connect
label: Authentik
args:
name: openid_connect
scope:
- openid
- profile
- email
response_type: code
issuer: 'https://auth.kube.huskypup.net/application/o/gitlab/'
discovery: true
client_auth_method: query
uid_field: sub
send_scope_to_token_endpoint: true
pkce: true
client_options:
identifier: '{{ .clientId }}'
secret: '{{ .clientSecret }}'
redirect_uri: 'https://gitlab.kube.huskypup.net/users/auth/openid_connect/callback'
data:
- secretKey: clientId
remoteRef:
key: gitlab-oauth
property: client-id
- secretKey: clientSecret
remoteRef:
key: gitlab-oauth
property: client-secret
@@ -0,0 +1,55 @@
# GitLab cross-site replication Phase 2b (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - Gitaly's gRPC
# has no TLS by default; routing it over the public UniFi WAN forward
# (needed because the direct-Netbird-IP path doesn't accept inbound
# connections to home, confirmed live the same way as the existing etcd
# quorum) would otherwise carry the gitaly_token, gitlab_shell_secret,
# and raw repository contents in plaintext.
#
# Real Let's Encrypt cert via the same DNS-01 ClusterIssuer already
# proven for every other *.huskypup.net cert in this cluster, rather
# than a self-signed cert - sidesteps having to distribute a custom CA
# to both home and the VPS (which already trust Let's Encrypt's public
# CA by default). Covers all 4 Gitaly floating hostnames as SANs - one
# shared cert, referenced by every node's tlsSecretName (or the global
# default), simpler than per-node certs.
#
# secretName matches the chart's own default (confirmed live via
# gitlab/templates/_gitaly.tpl's gitlab.gitaly.tls.secret helper:
# "<release-name>-gitaly-tls" = "gitlab-gitaly-tls") - global.gitaly.tls.
# enabled: true picks this up with no secretName override needed.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitlab-gitaly-tls
namespace: gitlab
spec:
secretName: gitlab-gitaly-tls
issuerRef:
name: letsencrypt-production
kind: ClusterIssuer
dnsNames:
- gitaly-0.ha.huskypup.net
- gitaly-1.ha.huskypup.net
- gitaly-2.ha.huskypup.net
- gitaly-vps.ha.huskypup.net
---
# Separate cert/secret for workhorse's internal-API TLS listener (port
# 8181, [gitlab] url every Gitaly node calls for gitlab-shell hooks) -
# different secretName (gitlab-workhorse-tls, chart default per
# gitlab/charts/gitlab/charts/webservice/templates/_helpers.tpl's
# workhorse.tls.secret helper) than Gitaly's own cert, kept separate
# rather than trying to share one Secret object across two different
# chart-expected names.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: gitlab-workhorse-tls
namespace: gitlab
spec:
secretName: gitlab-workhorse-tls
issuerRef:
name: letsencrypt-production
kind: ClusterIssuer
dnsNames:
- gitlab-internal-api.ha.huskypup.net
@@ -0,0 +1,61 @@
---
# GitLab cross-site replication Phase 2b (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - exposes each
# of home's 3 existing Gitaly nodes individually to the VPS, so Praefect
# can register the VPS as a 4th node in the SAME virtual storage and
# inter-Gitaly replication RPCs can reach each node directly. Per-pod
# (not per-StatefulSet), unlike the chart's own gitlab-gitaly-default
# Service - Praefect/Gitaly must address each node individually, not
# load-balance across them.
#
# Reached over the same public UniFi WAN forward already used for
# Postgres, NOT the Netbird mesh directly - confirmed live the direct-
# Netbird-IP path doesn't accept inbound connections to home (same gap
# already present for the existing etcd quorum; the earlier assumption
# that NodePort binds on a reachable Netbird interface was wrong).
# Targets Gitaly's TLS port (8076), not the plaintext one (8075) -
# Gitaly's gRPC has no built-in transport security, and this WAN hop
# would otherwise carry the gitaly_token, gitlab_shell_secret, and raw
# repository contents in plaintext. See gitaly-tls-certificate.yaml and
# global.gitaly.tls.enabled in values.yaml.
apiVersion: v1
kind: Service
metadata:
name: ha-gitaly-0
namespace: gitlab
spec:
type: NodePort
selector:
statefulset.kubernetes.io/pod-name: gitlab-gitaly-default-0
ports:
- port: 8076
targetPort: 8076
nodePort: 32446
---
apiVersion: v1
kind: Service
metadata:
name: ha-gitaly-1
namespace: gitlab
spec:
type: NodePort
selector:
statefulset.kubernetes.io/pod-name: gitlab-gitaly-default-1
ports:
- port: 8076
targetPort: 8076
nodePort: 32447
---
apiVersion: v1
kind: Service
metadata:
name: ha-gitaly-2
namespace: gitlab
spec:
type: NodePort
selector:
statefulset.kubernetes.io/pod-name: gitlab-gitaly-default-2
ports:
- port: 8076
targetPort: 8076
nodePort: 32448
@@ -0,0 +1,32 @@
# GitLab cross-site replication Phase 2b - exposes home's Rails internal
# API (port 8181, a dedicated internal-only listener, deliberately NOT
# exposed through the public Istio ingress on 8080) to the VPS's Gitaly
# node. Gitaly's gitlab-shell hooks call POST /api/v4/internal/{allowed,
# pre_receive,post_receive} against this whenever the VPS node is
# primary for a repository (per-repository election can cause this even
# in normal operation) or after a real failover.
#
# Reached over the same public UniFi WAN forward already used for
# Postgres/Gitaly, not the Netbird mesh directly (confirmed live that
# path doesn't accept inbound connections to home). Port stays 8181
# (unlike Gitaly's separate 8075/8076 split) - global.workhorse.tls.
# enabled in values.yaml replaces the plaintext listener with TLS on
# this SAME port rather than adding a second one. Carries the
# gitlab_shell_secret in a header, so this needed TLS just as much as
# Gitaly's gRPC did - see gitaly-tls-certificate.yaml's second
# Certificate (gitlab-workhorse-tls) and the
# gitlab-internal-api.ha.huskypup.net CoreDNS rewrite.
apiVersion: v1
kind: Service
metadata:
name: ha-gitlab-internal-api
namespace: gitlab
spec:
type: NodePort
selector:
app: webservice
release: gitlab
ports:
- port: 8181
targetPort: 8181
nodePort: 32449
@@ -0,0 +1,99 @@
# GitLab cross-site replication Phase 1b (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - pushes home's
# chart-generated secrets into Vault so the VPS's ESO can pull them back
# down BEFORE the VPS's Postgres becomes a real replica of home's.
#
# Why this matters: the GitLab chart generates these per-release, at
# random, on first install. Home's and the VPS's values are currently
# different. Once the VPS's Postgres is byte-for-byte replicating home's
# rows, the VPS Rails app will be reading home's encrypted-at-rest data
# (2FA secrets, CI/CD variables, integration tokens, PAT digests,
# application_settings encrypted columns) with the WRONG key unless these
# match - Rails raises OpenSSL::Cipher::CipherError on boot otherwise.
#
# gitlab-gitaly-secret/gitlab-praefect-secret are also needed for
# Phase 2's cross-site gRPC auth (the VPS's Gitaly/Praefect need the same
# tokens home's do, to be trusted as nodes of the same virtual storage).
#
# Same pattern as infrastructure/authentik/manifests/ha-postgres-app-pushsecret.yaml.
# Each of these secrets happens to have exactly one key (confirmed live),
# so one PushSecret + one ExternalSecret per secret, no enumeration needed.
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: gitlab-rails-secret-to-vps
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: gitlab-rails-secret
data:
- match:
secretKey: secrets.yml
remoteRef:
remoteKey: vps/gitlab-rails-secret
property: secrets.yml
---
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: gitlab-shell-secret-to-vps
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: gitlab-gitlab-shell-secret
data:
- match:
secretKey: secret
remoteRef:
remoteKey: vps/gitlab-gitlab-shell-secret
property: secret
---
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: gitlab-gitaly-secret-to-vps
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: gitlab-gitaly-secret
data:
- match:
secretKey: token
remoteRef:
remoteKey: vps/gitlab-gitaly-secret
property: token
---
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: gitlab-praefect-secret-to-vps
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: gitlab-praefect-secret
data:
- match:
secretKey: token
remoteRef:
remoteKey: vps/gitlab-praefect-secret
property: token
+90
View File
@@ -0,0 +1,90 @@
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - direct copy of
# infrastructure/authentik/manifests/ha-postgres-peerauth.yaml's proven
# fix. The gitlab namespace carries the same ambient-mesh STRICT default
# as authentik; without this, ztunnel resets the VPS/witness's connection
# (no mesh identity - they're not in this cluster) before the Postgres
# TLS handshake can even start, surfacing as "server closed the
# connection unexpectedly" on the replica side despite the NodePort/
# firewall path being entirely correct.
#
# Scoped to just each CNPG primary pod's ports via portLevelMtls, not the
# whole namespace - GitLab's own in-mesh east-west traffic (webservice
# <-> gitaly <-> redis <-> sidekiq <-> praefect) stays STRICT.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: allow-ha-gitlab-postgres-replication
namespace: gitlab
spec:
selector:
matchLabels:
cnpg.io/cluster: pg-gitlab
mtls:
mode: STRICT
portLevelMtls:
"5432":
mode: PERMISSIVE
# Floating-hostname port (see ha-postgres-nodeport.yaml) - needs its
# own entry, ambient's port-level mTLS enforcement is keyed on the
# port actually dialed (61442), not just the pod's real containerPort
# (5432) it eventually reaches (confirmed live for the authentik
# precedent, same mechanism here).
"61442":
mode: PERMISSIVE
---
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: allow-ha-praefect-postgres-replication
namespace: gitlab
spec:
selector:
matchLabels:
cnpg.io/cluster: pg-praefect
mtls:
mode: STRICT
portLevelMtls:
"5432":
mode: PERMISSIVE
"61443":
mode: PERMISSIVE
---
# Phase 2b (Gitaly cross-site replication) - the 3 Gitaly pods, exposed
# individually via ha-gitaly-nodeport.yaml for the VPS's 4th node to
# join the same virtual storage. Gitaly pods already carry
# istio.io/use-waypoint: none (chart default, gRPC doesn't tolerate L7
# waypoint processing well) but that only skips L7 processing - L4
# ztunnel mTLS enforcement is a separate concern, same fix needed as the
# Postgres precedent.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: allow-ha-gitaly-replication
namespace: gitlab
spec:
selector:
matchLabels:
app: gitaly
mtls:
mode: STRICT
portLevelMtls:
"8075":
mode: PERMISSIVE
---
# Phase 2b - the VPS's Gitaly node calling home's Rails internal API
# (ha-gitlab-internal-api-nodeport.yaml) for gitlab-shell hooks.
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: allow-ha-gitlab-internal-api
namespace: gitlab
spec:
selector:
matchLabels:
app: webservice
mtls:
mode: STRICT
portLevelMtls:
"8181":
mode: PERMISSIVE
@@ -0,0 +1,50 @@
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - same pattern
# and same reasoning as
# infrastructure/authentik/manifests/ha-postgres-app-pushsecret.yaml:
# pg-gitlab-app/pg-praefect-app are CNPG-generated, not Vault-native -
# each cluster independently generated its own random `app` role
# password at bootstrap time. Streaming replication keeps the two
# clusters' actual live passwords in sync (WAL includes role/password
# changes), but the two clusters' K8s Secret OBJECTS never resync on
# their own - this closes that gap, self-healing on refreshInterval
# instead of a one-time fix that goes stale on the next rotation.
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: pg-gitlab-app-to-vps
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: pg-gitlab-app
data:
- match:
secretKey: password
remoteRef:
remoteKey: vps/pg-gitlab-app
property: password
---
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: pg-praefect-app-to-vps
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: pg-praefect-app
data:
- match:
secretKey: password
remoteRef:
remoteKey: vps/pg-praefect-app
property: password
@@ -0,0 +1,56 @@
---
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md's "GitLab True
# Cross-Site Replication" section) - direct copy of the proven pattern
# from infrastructure/authentik/manifests/ha-postgres-nodeport.yaml.
# Exposes pg-gitlab's and pg-praefect's current primaries to the
# VPS/witness over the Netbird mesh for streaming replication - same
# selectors CNPG's own -rw ClusterIP Services use, just NodePort instead,
# since CNPG doesn't manage these objects and won't fight with them.
# Needs infrastructure/kyverno/policies/ha-failover-nodeport-exception.yaml
# (name pattern ha-* already matches, no change needed there).
#
# Second port on each (61442/61443, ClusterIP only) for the CoreDNS
# rewrite in infrastructure/coredns/manifests/: home's own pods resolving
# pg-gitlab.ha.huskypup.net / pg-praefect.ha.huskypup.net rewrite to
# THESE services specifically so they can reach them on the SAME port
# number the app tier will eventually be configured with (Phase 3) -
# rewrite only changes the resolved NAME, not the port a client then
# connects to (confirmed live for the authentik precedent).
apiVersion: v1
kind: Service
metadata:
name: ha-gitlab-postgres
namespace: gitlab
spec:
type: NodePort
selector:
cnpg.io/cluster: pg-gitlab
cnpg.io/instanceRole: primary
ports:
- name: nodeport-5432
port: 5432
targetPort: 5432
nodePort: 32442
- name: floating-port-61442
port: 61442
targetPort: 5432
---
apiVersion: v1
kind: Service
metadata:
name: ha-praefect-postgres
namespace: gitlab
spec:
type: NodePort
selector:
cnpg.io/cluster: pg-praefect
cnpg.io/instanceRole: primary
ports:
- name: nodeport-5432
port: 5432
targetPort: 5432
nodePort: 32443
- name: floating-port-61443
port: 61443
targetPort: 5432
@@ -26,10 +26,94 @@ spec:
primaryUpdateStrategy: unsupervised
# Explicit params added 2026-08-22 (GitLab cross-site replication plan)
# - this cluster ran on bare Postgres defaults before. A VPS replica
# can't replay WAL past a checkpoint recorded with higher settings than
# its own ("recovery aborted because of insufficient parameter
# settings", confirmed live for pg-authentik) - giving both sides an
# identical explicit block up front means nobody can tune one side
# later and silently break replication.
postgresql:
parameters:
max_connections: "200"
shared_buffers: "512MB"
effective_cache_size: "1536MB"
maintenance_work_mem: "128MB"
checkpoint_completion_target: "0.9"
wal_buffers: "16MB"
default_statistics_target: "100"
random_page_cost: "1.1"
effective_io_concurrency: "200"
work_mem: "4MB"
min_wal_size: "1GB"
max_wal_size: "4GB"
max_worker_processes: "4"
bootstrap:
initdb:
database: praefect_production
owner: app
# Backup to VPS MinIO, also the initial-seed source for the VPS's own
# pg-praefect replica (bootstrap.recovery, Phase 1c) - this cluster had
# no backup block at all before. See the matching comment on
# cnpg-cluster.yaml (pg-gitlab) in this same directory.
backup:
barmanObjectStore:
destinationPath: s3://cnpg-backups/pg-praefect
endpointURL: http://100.108.113.41:9000
s3Credentials:
accessKeyId:
name: vps-minio-secret
key: accesskey
secretAccessKey:
name: vps-minio-secret
key: secretkey
wal:
compression: gzip
maxParallel: 2
retentionPolicy: "30d"
monitoring:
enablePodMonitor: true
# GitLab cross-site replication Phase 1c stage 2 - same pattern as
# cnpg-cluster.yaml (pg-gitlab) in this same directory.
externalClusters:
- name: home
connectionParameters:
host: pg-praefect-rw
port: "5432"
dbname: praefect_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-praefect-replication
key: tls.crt
sslKey:
name: pg-praefect-replication
key: tls.key
sslRootCert:
name: pg-praefect-ca
key: ca.crt
- name: vps
connectionParameters:
host: 100.108.113.41
port: "32445"
dbname: praefect_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-praefect-vps-replication
key: tls.crt
sslKey:
name: pg-praefect-vps-replication
key: tls.key
sslRootCert:
name: pg-praefect-vps-ca
key: ca.crt
replica:
self: home
primary: home
source: home
@@ -0,0 +1,88 @@
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - the chart has
# no support for registering an externally-hosted Gitaly node into an
# existing Praefect virtual storage (confirmed live: no
# virtualStorages[].nodes/address-override key exists in the template,
# gitalyReplicas just counts StatefulSet ordinals). Overriding Praefect's
# rendered config.toml is the only way to add one.
#
# A values-only CONFIG_TEMPLATE_DIRECTORY redirect (extraEnv +
# extraVolumes/extraVolumeMounts) was tried first and confirmed NOT to
# work: charts/gitlab/charts/praefect/templates/statefulset.yaml's own
# volumes: list never calls the gitlab.extraVolumes helper (only
# volumeMounts does) - a chart limitation specific to this subchart, no
# values-only fix exists.
#
# THIS ConfigMap (praefect-ha-config) is deployed as a harmless,
# otherwise-unused object - it exists purely as a git-tracked reference
# copy of the content that actually matters. The REAL live config lives
# on the chart's own gitlab-praefect ConfigMap, which
# argocd-apps/apps/gitlab.yaml's ignoreDifferences now exempts from
# ArgoCD's normal drift-reconciliation (its `data` field specifically) -
# apply this file's content to it directly:
# kubectl -n gitlab patch configmap gitlab-praefect --type merge \
# -p "{\"data\":{\"config.toml.tpl\":\"$(kubectl -n gitlab get cm \
# praefect-ha-config -o jsonpath='{.data.config\.toml\.tpl}' | \
# python3 -c 'import sys,json; print(json.dumps(sys.stdin.read())[1:-1])')\"}}"
# (or simpler: kubectl -n gitlab get cm praefect-ha-config -o
# jsonpath='{.data}' | kubectl -n gitlab patch cm gitlab-praefect --type
# merge -p "{\"data\":$(cat -)}")
#
# Content below has since moved past byte-identical: nodes 0-2 now use
# tls:// on port 8076 (matching global.gitaly.tls.enabled in
# values.yaml) and the floating hostnames gitaly-{0,1,2}.ha.huskypup.net
# (CoreDNS-rewritten locally for home's own traffic, same reasoning as
# pg-gitlab.ha.huskypup.net) rather than in-cluster DNS names or the
# plaintext scheme - this is what makes the SAME addresses usable once
# the VPS joins as a 4th node, without home's own Praefect needing a
# different config than what it hands the VPS as peer addresses. Praefect
# trusts the cert via the pod's normal system CA bundle - it's a real
# Let's Encrypt cert (gitaly-tls-certificate.yaml), not self-signed, so
# no extra CA trust config is needed here.
apiVersion: v1
kind: ConfigMap
metadata:
name: praefect-ha-config
namespace: gitlab
data:
config.toml.tpl: |
# TCP address to listen on
listen_addr = '0.0.0.0:8075'
prometheus_listen_addr = '0.0.0.0:9236'
prometheus_exclude_database_from_default_metrics = true
[failover]
enabled = true
read_only_after_failover = true
[auth]
token = {% file.Read "/etc/gitlab-secrets/praefect/praefect_token" | strings.TrimSpace | data.ToJSON %}
transitioning = false
[logging]
[[virtual_storage]]
name = 'default'
[[virtual_storage.node]]
storage = 'gitlab-gitaly-default-0'
address = 'tls://gitaly-0.ha.huskypup.net:8076'
token = {% file.Read "/etc/gitlab-secrets/praefect/gitaly_token" | strings.TrimSpace | data.ToJSON %}
[[virtual_storage.node]]
storage = 'gitlab-gitaly-default-1'
address = 'tls://gitaly-1.ha.huskypup.net:8076'
token = {% file.Read "/etc/gitlab-secrets/praefect/gitaly_token" | strings.TrimSpace | data.ToJSON %}
[[virtual_storage.node]]
storage = 'gitlab-gitaly-default-2'
address = 'tls://gitaly-2.ha.huskypup.net:8076'
token = {% file.Read "/etc/gitlab-secrets/praefect/gitaly_token" | strings.TrimSpace | data.ToJSON %}
[database]
host = 'pg-praefect-rw.gitlab.svc.cluster.local'
port = 5432
user = 'app'
password = {% file.Read "/etc/gitlab-secrets/praefect/db_password" | strings.TrimSpace | data.ToJSON %}
dbname = 'praefect_production'
sslmode = 'disable'
+91 -5
View File
@@ -54,6 +54,49 @@ global:
internal:
names: [] # No internal Gitaly, using Praefect
external: [] # Praefect configured below
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - Gitaly's
# gRPC has no TLS by default. The VPS's 4th node has to be reached
# over the same public UniFi WAN forward already used for Postgres
# (confirmed live the direct-Netbird-IP path doesn't accept inbound
# connections to home, same as the existing etcd quorum) - without
# this, that WAN hop would carry the gitaly_token,
# gitlab_shell_secret, and raw repository contents in plaintext.
# Chart-native support (tls_listen_addr alongside the existing
# plaintext listener, both stay active) - cert is a real Let's
# Encrypt cert via the same DNS-01 ClusterIssuer already used
# everywhere else (gitaly-tls-certificate.yaml), not a self-signed
# one, so no custom CA needs distributing to either site.
tls:
enabled: true
# Gitaly's [gitlab] url (the internal-API callback every Gitaly node
# uses for gitlab-shell hooks) - a real, TLS-supported override,
# confirmed live via gitlab/charts/gitlab/templates/_workhorse.tpl.
# Uses the SAME floating-hostname + CoreDNS-local-rewrite pattern
# already proven for Postgres and Gitaly's own node addresses: home's
# 3 Gitaly nodes resolve this locally (no WAN round-trip), the VPS's
# 4th node resolves it via the UniFi WAN forward - one consistent
# URL for every node regardless of site.
#
# tls.enabled REVERTED 2026-08-22 - broke the live site (HTTP 400
# "Client sent an HTTP request to an HTTPS server" on
# gitlab.kube.huskypup.net). Root cause: port 8181 is NOT internal-API
# -only - confirmed live via `kubectl get httproute gitlab-web` that
# ALL public traffic (gitlab.kube.huskypup.net, routed through Envoy/
# Istio edge gateway) also targets gitlab-webservice-default:8181.
# global.workhorse.tls.enabled replaces the plaintext listener with
# TLS on that SAME port chart-wide, for both the public site AND the
# internal API - there's no chart-native way to TLS one without the
# other. Envoy terminates public HTTPS and proxies to the pod in
# plaintext, so a TLS-only pod listener 400s every public request.
# host: left in place (harmless without tls.enabled - only used for
# the [gitlab] url string, not port selection). Internal-API-over-WAN
# transport security for the VPS's future Gitaly node needs a
# different mechanism (e.g. network-layer TLS/mTLS via Istio, or a
# second dedicated port if the chart ever adds one) - not solved here.
workhorse:
host: gitlab-internal-api.ha.huskypup.net
# Praefect configuration
praefect:
@@ -68,6 +111,12 @@ global:
- name: default
gitalyReplicas: 3 # Production HA
maxUnavailable: 1
# Required once global.gitaly.tls.enabled: true - the chart's own
# NOTES.txt validation rejects the render without it ("not
# specified ('default')... not supported", confirmed live).
# Matches gitlab.gitaly.tls.secret's default name/keys
# (gitaly-tls-certificate.yaml issues into this exact secret).
tlsSecretName: gitlab-gitaly-tls
# Praefect PostgreSQL configuration
psql:
@@ -89,6 +138,19 @@ global:
# Application Configuration
appConfig:
# OmniAuth SSO Configuration
#
# CORRECTED 2026-08-21 (second time - see git history for the wrong
# 2026-08-20 fix that removed this list entirely). This IS the real,
# actually-processed OIDC config mechanism for the CNG webservice
# image (chart's gitlab.appConfig.omniauth.configuration template,
# infra/_omniauth.tpl) - GITLAB_OMNIBUS_CONFIG below is an
# omnibus-image-only convention this image never processes, so it
# was always a documentation-only no-op despite looking functional.
# gitlab-oidc-secret's `provider` key now holds a full YAML-encoded
# provider block (apps/gitlab/manifests/external-secret-oidc.yaml),
# loaded via Ruby's YAML.load_file at container start - not raw
# client_id/secret strings, which is why plain GITLAB_OIDC_CLIENT_ID/
# SECRET keys alone (yesterday's assumption) were never enough.
omniauth:
enabled: true
allowSingleSignOn: ['openid_connect']
@@ -289,11 +351,35 @@ gitlab:
cpu: 250m
memory: 512Mi
# Virtual storage configuration
virtualStorages:
- name: default
gitalyReplicas: 1 # Homelab sizing
maxUnavailable: 1
# (dead config removed 2026-08-22: this virtualStorages block never
# actually did anything - confirmed live the chart's Praefect
# ConfigMap template iterates global.praefect.virtualStorages, not
# this one, so gitalyReplicas: 3 up there always won regardless of
# what this said. See global.praefect.virtualStorages above for the
# real config.)
#
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md): tried a
# values-only CONFIG_TEMPLATE_DIRECTORY redirect (extraEnv +
# extraVolumes/extraVolumeMounts) to add a VPS-hosted Gitaly node the
# chart has no mechanism to register - confirmed live this doesn't
# work: extraVolumeMounts and extraEnv both render correctly, but
# extraVolumes is a dead values key for THIS subchart specifically -
# charts/gitlab/charts/praefect/templates/statefulset.yaml's own
# volumes: list never calls the gitlab.extraVolumes helper at all
# (only volumeMounts does), so the mount has nothing to mount and the
# pod would fail to schedule. No values-only fix exists.
#
# Real fix: argocd-apps/apps/gitlab.yaml's ignoreDifferences now
# covers ConfigMap gitlab-praefect's `data` field, so Helm creates
# the object (with its own 3-node content) but ArgoCD stops
# reconciling its content afterward. The actual multi-node content
# lives in apps/gitlab/manifests/praefect-ha-configmap.yaml as a
# git-tracked reference/documentation copy (deployed under its own
# name, praefect-ha-config, harmless and otherwise unused) - the
# live gitlab-praefect ConfigMap gets kubectl-patched with that same
# content directly, same "documented but manually-applied"
# convention as every other cross-cluster secret in this plan.
# GitLab Exporter for Prometheus metrics
gitlab-exporter:
+13
View File
@@ -33,6 +33,19 @@ spec:
jsonPointers:
- /spec/ports
- /spec/selector
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - the chart
# has no mechanism to register an externally-hosted (VPS) Gitaly node
# into Praefect's virtual storage. Lets the live ConfigMap's `data`
# be kubectl-patched directly (see apps/gitlab/manifests/
# praefect-ha-configmap.yaml for the content + patch command) without
# ArgoCD reverting it on the next sync - Helm still creates the
# object and everything else about it stays normally managed.
- group: ""
kind: ConfigMap
name: gitlab-praefect
jsonPointers:
- /data
syncPolicy:
automated:
prune: true
+22
View File
@@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: coredns
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0" # cluster DNS - as early as anything can depend on it
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: infrastructure
source:
repoURL: https://gitlab.kube.huskypup.net/Scooby/Homelabv4.git
targetRevision: main
path: infrastructure/coredns/manifests
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
+33
View File
@@ -0,0 +1,33 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: vps-gitlab
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3" # after vps-cnpg (wave 1) and vps-eso (wave 1)
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: vps-standby
sources:
- repoURL: https://charts.gitlab.io/
chart: gitlab
targetRevision: 7.7.0
helm:
valueFiles:
- $values/infrastructure/vps-standby/gitlab/values.yaml
- repoURL: https://gitlab.kube.huskypup.net/Scooby/Homelabv4.git
targetRevision: main
ref: values
- repoURL: https://gitlab.kube.huskypup.net/Scooby/Homelabv4.git
targetRevision: main
path: infrastructure/vps-standby/gitlab/manifests
destination:
name: vps-standby
namespace: gitlab
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+22
View File
@@ -0,0 +1,22 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: vps-eso
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1" # before any Application whose secrets it manages (authentik is wave 3)
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: vps-standby
source:
repoURL: https://gitlab.kube.huskypup.net/Scooby/Homelabv4.git
targetRevision: main
path: infrastructure/vps-eso/manifests
destination:
name: vps-standby
namespace: external-secrets
syncPolicy:
automated:
prune: true
selfHeal: true
+32 -4
View File
@@ -1,12 +1,35 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20 (user: "make the repo proper and not have
# sensitive things in the files"). The value itself was already the
# genuine live secret ArgoCD's own OIDC client uses (apps/argocd/
# manifests/external-secret.yaml pulls the same value from Vault at
# secret/argocd-oauth) - Vault was already the source of truth on the
# APP side, this blueprint (the Authentik PROVIDER side) just had its own
# separate hardcoded plaintext copy instead of also reading from Vault.
#
# Converted ConfigMap -> ExternalSecret (chart supports mounting
# blueprints from Secrets too - see blueprints.secrets in values.yaml).
# client_id stays inline - it's not a secret, same convention as every
# other blueprint here.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-argocd
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-argocd
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
argocd.yaml: |-
version: 1
metadata:
@@ -20,7 +43,7 @@ data:
attrs:
name: ArgoCD
client_id: 3791058a7de278045e5ec36be29f7a33d635119b8c786f20c86b185139c28841
client_secret: f787416822f5bf4c46fe9170e63d1bf9520bab7703d53341aaaec107c8c4ba253d96c30b70d23a1c0b5a401ae1ae4bfb5db06092d338e29e0b0f04cd29bc0b36
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
@@ -47,3 +70,8 @@ data:
slug: argocd
policy_engine_mode: any
provider: !KeyOf argocd-provider
data:
- secretKey: clientSecret
remoteRef:
key: argocd-oauth
property: client-secret
+33 -4
View File
@@ -1,12 +1,32 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20 (user: "make the repo proper and not have
# sensitive things in the files"). Unlike argocd/n8n/etc, Vault had NO
# copy of this at all before this fix - the ONLY place it existed was
# here and in gitlab-oidc-secret (a manually kubectl-created Secret in
# the gitlab namespace, apps/gitlab/values.yaml:166 - also fixed
# alongside this). Created secret/gitlab-oauth in Vault with the existing
# live value (not rotated - this is GitLab's actual working OIDC
# credential right now, changing it would break login until both sides
# are updated in lockstep).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-gitlab
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-gitlab
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
gitlab.yaml: |-
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
@@ -22,13 +42,17 @@ data:
attrs:
name: GitLab
client_id: 70b9da5c8166cea0ba504a869824020bce6636
client_secret: 2e9c0c5b7a8969eb782d08bf1bf3d50ad96f8a73d9c18d1e4141816aebe14a622e8064bf5a5bce835d80e89cdc5f4cc6cf4a8c8234556647dc8884888866bd7d
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
redirect_uris:
- url: "https://gitlab.kube.huskypup.net/users/auth/openid_connect/callback"
matching_mode: strict
# Real GitLab on the VPS (infrastructure/vps-standby/gitlab/) -
# shares this same provider/client_id, added 2026-08-21.
- url: "https://gitlab.vps.huskypup.net/users/auth/openid_connect/callback"
matching_mode: strict
enabled: true
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [scope_name, openid]]
@@ -51,3 +75,8 @@ data:
slug: gitlab
policy_engine_mode: any
provider: !KeyOf gitlab-provider
data:
- secretKey: clientSecret
remoteRef:
key: gitlab-oauth
property: client-secret
@@ -1,12 +1,29 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20. Unlike argocd/nextcloud, Vault had NO copy of
# this at all - the only places it existed were here AND in
# infrastructure/grafana/manifests/grafana-oauth-secret.yaml (also fixed
# alongside this, same Vault path). Created secret/grafana-oauth with the
# existing live value (not rotated - changing it would break login until
# both sides are updated together).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-grafana
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-grafana
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
grafana.yaml: |-
version: 1
metadata:
@@ -66,7 +83,7 @@ data:
attrs:
name: Grafana
client_id: bd03e9139dd2063c6c44c4d2f65f51d69de3ba0b6d6b1b9b41c255d2376d2dcc
client_secret: 6b6f2deecfe8fd56cae9c512cc71eedd463d67ff08f24c816b15b15e78ce36bc4f06a2276c5ffe3f67799d935994a32ed32cdc02ec674f279c82d7cfe3ca05d5
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
@@ -97,3 +114,8 @@ data:
slug: grafana
policy_engine_mode: any
provider: !KeyOf grafana-provider
data:
- secretKey: clientSecret
remoteRef:
key: grafana-oauth
property: client-secret
@@ -1,12 +1,27 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20. Same story as argocd-blueprint.yaml: the value
# was already Vault's source of truth on the app side (secret/
# guacamole-oauth), this blueprint just had its own separate hardcoded
# plaintext copy.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-guacamole
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-guacamole
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
guacamole.yaml: |-
version: 1
metadata:
@@ -46,7 +61,7 @@ data:
attrs:
name: Guacamole
client_id: a55cbe3c6d2ae30c05c86385802ffcfd76a972c22c8d039f000281d4a149fd44
client_secret: 718d4c4816aa68767a6a0da0fa4099d492473c700ba236cbeb64d294762b9548acda7e716ba11b2ad7729d6a1c16c7890b227e3b2dcfc35fd4af1eb0d6662b0f
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
@@ -81,3 +96,8 @@ data:
slug: guacamole
policy_engine_mode: any
provider: !KeyOf guacamole-provider
data:
- secretKey: clientSecret
remoteRef:
key: guacamole-oauth
property: client-secret
@@ -0,0 +1,36 @@
# Multi-site active failover pilot (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - closes the gap
# the user correctly flagged: manually kubectl/ssh-copying secrets between
# home and the VPS defeats the entire point of having Vault. This is the
# first piece of a real Vault -> VPS pipeline (infrastructure/vps-eso/ has
# the other half, ESO running on the VPS itself pulling this back down).
#
# pg-authentik-app is CNPG-generated, not Vault-native - each cluster
# (home and VPS) independently generates its own random password for the
# `app` role at bootstrap time. Since VPS's Postgres now REPLICATES from
# home (WAL includes role/password changes), the two clusters' actual live
# passwords are only in sync because of that replication - but the two
# clusters' K8S SECRET OBJECTS never resync on their own (confirmed live
# 2026-08-20: VPS's copy was stale). Pushing home's value into Vault, with
# VPS's ESO pulling it back down on refreshInterval, makes this self-
# healing instead of a manual one-time fix that goes stale again on the
# next password rotation.
apiVersion: external-secrets.io/v1alpha1
kind: PushSecret
metadata:
name: pg-authentik-app-to-vps
namespace: authentik
spec:
refreshInterval: 5m
secretStoreRefs:
- name: vault-backend
kind: ClusterSecretStore
selector:
secret:
name: pg-authentik-app
data:
- match:
secretKey: password
remoteRef:
remoteKey: vps/pg-authentik-app
property: password
@@ -6,6 +6,18 @@
# object and won't fight with it. Needs
# infrastructure/kyverno/policies/ha-failover-nodeport-exception.yaml
# (disallow-nodeport-services is enforced cluster-wide otherwise).
#
# Second port (61432, ClusterIP only - no nodePort needed) added for the
# CoreDNS rewrite in infrastructure/coredns/manifests/: home's own pods
# resolving pg-authentik.ha.huskypup.net rewrite to THIS service (not
# CNPG's own pg-authentik-rw) specifically so they can reach it on the
# SAME port number the app tier is configured with (61432, matching the
# external floating hostname's port - see infrastructure/authentik/
# values.yaml's AUTHENTIK_POSTGRESQL__PORT) - CoreDNS rewrite only
# changes the resolved NAME, not the port the client then connects to,
# so the target service has to actually listen on that same port too, or
# the connection times out even though DNS resolves correctly (confirmed
# live 2026-08-20).
apiVersion: v1
kind: Service
metadata:
@@ -17,6 +29,10 @@ spec:
cnpg.io/cluster: pg-authentik
cnpg.io/instanceRole: primary
ports:
- port: 5432
- name: nodeport-5432
port: 5432
targetPort: 5432
nodePort: 32432
- name: floating-port-61432
port: 61432
targetPort: 5432
@@ -38,3 +38,14 @@ spec:
portLevelMtls:
"5432":
mode: PERMISSIVE
# Multi-site active failover pilot, floating hostname (see
# infrastructure/authentik/manifests/ha-postgres-nodeport.yaml): the
# ha-authentik-postgres Service also listens on 61432 (-> targetPort
# 5432, same pods) so home's own pods can reach it via the
# pg-authentik.ha.huskypup.net CoreDNS rewrite on the same port the
# app tier is configured with externally. Needed its own PERMISSIVE
# entry - confirmed live 2026-08-20 that ambient's port-level mTLS
# enforcement is keyed on the port actually dialed (61432), not just
# the pod's real containerPort (5432) it eventually reaches.
"61432":
mode: PERMISSIVE
+23 -4
View File
@@ -1,12 +1,26 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20. Same story as argocd-blueprint.yaml: the value
# was already Vault's source of truth on the app side (secret/n8n-oauth),
# this blueprint just had its own separate hardcoded plaintext copy.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-n8n
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-n8n
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
n8n.yaml: |-
version: 1
metadata:
@@ -20,7 +34,7 @@ data:
attrs:
name: n8n
client_id: e55cbe3c6d2ae30c05c86385802ffcfd76a972c22c8d039f000281d4a149fd44
client_secret: 718d4c4816aa68767a6a0da0fa4099d492473c700ba236cbeb64d294762b9548acda7e716ba11b2ad7729d6a1c16c7890b227e3b2dcfc35fd4af1eb0d6662b0f
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
@@ -47,3 +61,8 @@ data:
slug: n8n
policy_engine_mode: any
provider: !KeyOf n8n-provider
data:
- secretKey: clientSecret
remoteRef:
key: n8n-oauth
property: client-secret
@@ -1,12 +1,27 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20. Same story as argocd-blueprint.yaml: the value
# was already Vault's source of truth on the app side (secret/
# nextcloud-oauth), this blueprint just had its own separate hardcoded
# plaintext copy.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-nextcloud
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-nextcloud
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
nextcloud.yaml: |-
version: 1
metadata:
@@ -20,7 +35,7 @@ data:
attrs:
name: Nextcloud
client_id: HmTGnUfhCvjFFbUUAne7JLrucNC2X675JicPbEnQ
client_secret: qTUf4NYjzUuAqfNV5D7SFPuuhJJwVltU6U3LAuJVy7dddJWeJSv2B4wKaRxbbHqpIiSfYPDaKujhEysKbOWUnf4n9OHPKJ0bu1I2EzkYtChO1Gq4gUv5EHPTzQlMBPnl
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
@@ -50,3 +65,8 @@ data:
slug: nextcloud
policy_engine_mode: any
provider: !KeyOf nextcloud-provider
data:
- secretKey: clientSecret
remoteRef:
key: nextcloud-oauth
property: client-secret
@@ -1,12 +1,27 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20. Same story as argocd-blueprint.yaml: the value
# was already Vault's source of truth on the app side (secret/
# rancher-oauth), this blueprint just had its own separate hardcoded
# plaintext copy.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-rancher
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-rancher
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
rancher.yaml: |-
# yaml-language-server: $schema=https://goauthentik.io/blueprints/schema.json
version: 1
@@ -21,7 +36,7 @@ data:
attrs:
name: Rancher
client_id: 94a6beeaa91d0d0407cf8af807e8208a611cf3f25ae42161e697a8dccd563449
client_secret: 19ebd4545ba74a93c220d4471af634302c5991204cf650cabd522b0864d7617ded2c59b470ab6de7a5801e7de4c84e4b9741b983170815419c9503d55f849794
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
@@ -51,4 +66,8 @@ data:
slug: rancher
policy_engine_mode: any
provider: !KeyOf rancher-provider
data:
- secretKey: clientSecret
remoteRef:
key: rancher-oauth
property: client-secret
+28 -11
View File
@@ -13,10 +13,23 @@ global:
# First secret: Authentik-specific settings (AUTHENTIK_SECRET_KEY)
- secretRef:
name: authentik
# Second secret: Database credentials from CNPG
- secretRef:
name: pg-authentik-app
prefix: AUTHENTIK_POSTGRESQL__
# The second secretRef (pg-authentik-app, prefix: AUTHENTIK_POSTGRESQL__)
# that used to live here is GONE - found live 2026-08-20 that it was
# actively breaking things, not just redundant. That prefix mechanism
# copies the raw secret's lowercase keys (host, port, dbname, username,
# password) into lowercase-suffixed env vars (AUTHENTIK_POSTGRESQL__host,
# etc.) - env var names are case-sensitive to Kubernetes, but authentik's
# own generic AUTHENTIK_*-prefixed env-var scanner is NOT, and it was
# picking up the lowercase AUTHENTIK_POSTGRESQL__host=pg-authentik-rw
# instead of the explicit uppercase AUTHENTIK_POSTGRESQL__HOST override
# below - silently connecting to the local CNPG service on port 5432
# the whole time regardless of what HOST/PORT were explicitly set to,
# surfacing as an unexplained "connection timeout expired" loop (the
# local hostname resolves fine in-cluster but on the VPS the request
# apparently doesn't complete - never fully root-caused, just confirmed
# dropping this duplicate source fixes it). Every key that secretRef
# used to provide (dbname/username/password) is already explicitly set
# via valueFrom below, so nothing is lost by removing it.
env:
# Configure external URL for proper OIDC discovery responses
- name: AUTHENTIK_URL
@@ -64,22 +77,26 @@ global:
- name: AUTHENTIK_POSTGRESQL__PORT
value: "61432"
# Blueprints - Mount ConfigMaps for auto-discovery
# Blueprints - Mount ConfigMaps for auto-discovery (no secrets) or
# Secrets (client_secret-bearing ones, ExternalSecret-templated from
# Vault - found and fixed 2026-08-20, see each file's own header for
# what it used to look like).
blueprints:
configMaps:
- authentik-blueprints-forward-auth
- authentik-blueprints-prometheus
- authentik-blueprints-ceph
- authentik-blueprints-home-assistant
- authentik-blueprints-percona-everest
- authentik-blueprints-netbird
secrets:
- authentik-blueprints-gitlab
- authentik-blueprints-nextcloud
- authentik-blueprints-argocd
- authentik-blueprints-grafana
- authentik-blueprints-forward-auth
- authentik-blueprints-prometheus
- authentik-blueprints-n8n
- authentik-blueprints-ceph
- authentik-blueprints-home-assistant
- authentik-blueprints-guacamole
- authentik-blueprints-percona-everest
- authentik-blueprints-rancher
- authentik-blueprints-netbird
- authentik-blueprints-vault
# Enable Prometheus metrics
+36 -4
View File
@@ -1,12 +1,30 @@
---
apiVersion: v1
kind: ConfigMap
# Was a plain ConfigMap with client_secret hardcoded in plaintext - found
# and fixed 2026-08-20. Unlike argocd/nextcloud, Vault had NO copy of
# this at all - the only places it existed were here AND in
# infrastructure/vault/manifests/vault-init-configmap.yaml (embedded in a
# shell script that runs `vault write auth/oidc/config` once at cluster
# init - also fixed alongside this, same Vault path). Created
# secret/vault-oauth with the existing live value (not rotated - this is
# what Vault's OWN oidc auth method is currently configured with).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: authentik-blueprints-vault
namespace: authentik
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: authentik-blueprints-vault
creationPolicy: Owner
template:
metadata:
labels:
goauthentik.io/blueprint: "true"
data:
data:
vault.yaml: |-
version: 1
metadata:
@@ -20,13 +38,22 @@ data:
attrs:
name: Vault
client_id: 9816a5ae7e7914b5d18f4ab939d011a98f8c8d6b3bb6777c46431afa06ac4a85
client_secret: ed2ba1c6378c7a46341b5162f39a7fab80e37596b01ed387c3719e8e0040344cf1daa307476c2e7a7f75041b3979275b1ebf00bb8bad94c864b4a38ded544f7b
client_secret: "{{ .clientSecret }}"
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
authentication_flow: !Find [authentik_flows.flow, [slug, default-authentication-flow]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
redirect_uris:
- url: "https://vault.kube.huskypup.net/ui/vault/auth/oidc/oidc/callback"
matching_mode: strict
# VPS's own standby Vault instance (infrastructure/vps-standby/vault/)
# shares this SAME Authentik provider/client_id - found missing
# 2026-08-21 while testing VPS vault login ("missing auth_url" -
# Vault silently returns no auth_url when the UI's computed
# redirect_uri isn't in allowed_redirect_uris; same fix needed on
# the VPS Vault's own auth/oidc/role/default, done separately since
# it's a fully independent Vault instance/backend).
- url: "https://vault.vps.huskypup.net/ui/vault/auth/oidc/oidc/callback"
matching_mode: strict
- url: "http://localhost:8250/oidc/callback"
matching_mode: strict
property_mappings:
@@ -49,3 +76,8 @@ data:
slug: vault
policy_engine_mode: any
provider: !KeyOf vault-provider
data:
- secretKey: clientSecret
remoteRef:
key: vault-oauth
property: client-secret
@@ -19,6 +19,17 @@ spec:
selector:
dnsZones:
- "kube.huskypup.net"
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) -
# first real Let's Encrypt cert needed for anything under
# ha.huskypup.net (the floating-hostname zone used for
# cross-site Postgres/Gitaly addressing) - everything
# before this used CNPG's own self-signed certs, never hit
# this gap. Confirmed live: DNS-01 order failed with "no
# configured challenge solvers can be used for this
# challenge" until this zone was added. Same Cloudflare
# account/token as kube.huskypup.net.
- "ha.huskypup.net"
---
apiVersion: cert-manager.io/v1
@@ -0,0 +1,100 @@
# Brings kube-system/coredns's Corefile under GitOps management. It was
# previously only ever edited live via kubectl (3 existing rewrite rules
# for auth/rancher/netbird.kube.huskypup.net, none of them git-tracked
# anywhere in this repo) - every future change meant a manual,
# classifier-gated live edit to a cluster-wide DNS config instead of a
# normal git commit ArgoCD applies on its own. No Talos/kubeadm
# ownerReferences on this ConfigMap or the coredns Deployment (confirmed
# live 2026-08-20), so it's safe to take over here.
#
# No pod restart needed after a sync - the Corefile's own `reload` plugin
# (already present below) watches the mounted file and hot-reloads within
# ~30-60s of the ConfigMap volume actually updating on disk (kubelet
# propagates ConfigMap changes to mounted volumes automatically, typically
# within ~60-90s).
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
log . {
class error
}
prometheus :9153
# Rewrite auth.kube.huskypup.net to ingress gateway (NOT authentik-server directly)
# This ensures HTTPS works because the ingress gateway has the wildcard TLS cert.
# The old rewrite to authentik-server caused TLS failures for OIDC token exchange.
rewrite name auth.kube.huskypup.net istio-ingressgateway.istio-system.svc.cluster.local
# Rewrite rancher.kube.huskypup.net to internal Istio ingress gateway
rewrite name rancher.kube.huskypup.net istio-ingressgateway.istio-system.svc.cluster.local
# Rewrite netbird.kube.huskypup.net to internal Istio ingress gateway
rewrite name netbird.kube.huskypup.net istio-ingressgateway.istio-system.svc.cluster.local
# Multi-site active failover pilot (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md): home's
# own pods resolving the floating pg-authentik.ha.huskypup.net
# record always go straight to a local service instead of
# round-tripping out through the UniFi WAN port-forward and back
# in - this UniFi doesn't support NAT hairpin/loopback for its
# own forwarded ports (confirmed live 2026-08-20: DNS resolved
# fine, TCP connect failed). Points at ha-authentik-postgres, NOT
# CNPG's own pg-authentik-rw directly - rewrite only changes the
# resolved name, not the port a client then connects to, and the
# app tier is configured for port 61432 (matching the external
# floating hostname) - pg-authentik-rw only listens on 5432,
# ha-authentik-postgres additionally listens on 61432 (see
# infrastructure/authentik/manifests/ha-postgres-nodeport.yaml)
# specifically so this rewrite target actually has that port.
rewrite name pg-authentik.ha.huskypup.net ha-authentik-postgres.authentik.svc.cluster.local
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - same
# reasoning as pg-authentik.ha.huskypup.net above: home's own pods
# go straight to the local NodePort Service instead of round-
# tripping out through the UniFi WAN forward and back in (no NAT
# hairpin support, confirmed live for the authentik precedent).
rewrite name pg-gitlab.ha.huskypup.net ha-gitlab-postgres.gitlab.svc.cluster.local
rewrite name pg-praefect.ha.huskypup.net ha-praefect-postgres.gitlab.svc.cluster.local
# GitLab cross-site replication Phase 2b (Gitaly) - home's own
# Praefect dials these SAME floating hostnames it hands to the
# VPS's Gitaly node as peer addresses (Praefect uses one address
# per node for everyone, not different addresses per caller), so
# home's own in-cluster traffic to its own local Gitaly nodes
# needs to resolve locally too, same reasoning as pg-*.ha above.
rewrite name gitaly-0.ha.huskypup.net ha-gitaly-0.gitlab.svc.cluster.local
rewrite name gitaly-1.ha.huskypup.net ha-gitaly-1.gitlab.svc.cluster.local
rewrite name gitaly-2.ha.huskypup.net ha-gitaly-2.gitlab.svc.cluster.local
# global.workhorse.host override (apps/gitlab/values.yaml) - every
# Gitaly node's [gitlab] url, home's own included, same local-
# rewrite reasoning as the entries above.
rewrite name gitlab-internal-api.ha.huskypup.net ha-gitlab-internal-api.gitlab.svc.cluster.local
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
forward . 1.1.1.1 8.8.8.8 {
max_concurrent 1000
}
cache 30 {
disable success cluster.local
disable denial cluster.local
}
loop
reload
loadbalance
}
@@ -1,8 +1,33 @@
---
apiVersion: v1
kind: Secret
# Was a plain Secret with a live UniFi API key committed in plaintext -
# found and fixed 2026-08-20 (same class of issue as the Cloudflare token
# in infrastructure/cert-manager/manifests/secret-cf-token.yaml, which was
# already rotated to this pattern - this one was missed at the time).
# Moved to Vault+ExternalSecret, matching that precedent.
#
# The value in Vault right now (secret/unifi-api-key) is still the
# ORIGINAL key that was exposed in git history - rotating it (Settings ->
# Admins -> API Keys on the UniFi controller at https://172.28.110.1, no
# self-service API for this) is a separate, still-pending step. Once
# rotated:
# vault kv put secret/unifi-api-key api-key=<new key>
# ESO picks it up automatically (refreshInterval below), no restart
# needed on external-dns's side.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: external-dns-unifi-secret
namespace: external-dns
stringData:
api-key: 3Qha5hupHsCkkFyQb8z-T2kh-BasIGSH
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: external-dns-unifi-secret
creationPolicy: Owner
data:
- secretKey: api-key
remoteRef:
key: unifi-api-key
property: api-key
@@ -1,12 +1,32 @@
# infrastructure/grafana/grafana-oauth-secret.yaml
# Grafana OAuth secret - hardcoded from blueprint
apiVersion: v1
kind: Secret
# Was a plain Secret with the OIDC client_id/client_secret hardcoded in
# plaintext (the file's own old comment even said "hardcoded from
# blueprint") - found and fixed 2026-08-20 alongside
# infrastructure/authentik/grafana-blueprint.yaml (same value, same Vault
# path - see that file for the full story).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: grafana-authentik-oauth
namespace: grafana
type: Opaque
stringData:
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: "bd03e9139dd2063c6c44c4d2f65f51d69de3ba0b6d6b1b9b41c255d2376d2dcc"
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "6b6f2deecfe8fd56cae9c512cc71eedd463d67ff08f24c816b15b15e78ce36bc4f06a2276c5ffe3f67799d935994a32ed32cdc02ec674f279c82d7cfe3ca05d5"
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: grafana-authentik-oauth
creationPolicy: Owner
template:
type: Opaque
data:
GF_AUTH_GENERIC_OAUTH_CLIENT_ID: "{{ .clientId }}"
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: "{{ .clientSecret }}"
data:
- secretKey: clientId
remoteRef:
key: grafana-oauth
property: client-id
- secretKey: clientSecret
remoteRef:
key: grafana-oauth
property: client-secret
@@ -83,6 +83,25 @@ spec:
- source:
ipBlocks:
- "10.244.0.0/16"
# GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md): CNPG
# streaming replication from the VPS/witness, arriving via the
# ha-gitlab-postgres/ha-praefect-postgres NodePorts - no mesh
# identity at all, so no source.namespaces rule above can match them.
# Scoped by destination port instead, matching the port-scoped
# PERMISSIVE PeerAuthentication in ha-peerauth.yaml (same root cause,
# same fix, one layer up - mTLS being allowed through isn't enough by
# itself, this ALLOW policy independently denies anything not
# matching one of the rules above). Same pattern already proven for
# Authentik in infrastructure/istio/manifests/mesh/authorization-policies.yaml.
- to:
- operation:
ports:
- "5432"
- "61442" # pg-gitlab floating-hostname port, see ha-postgres-nodeport.yaml
- "61443" # pg-praefect floating-hostname port, see ha-postgres-nodeport.yaml
- "8075" # Gitaly gRPC, see ha-gitaly-nodeport.yaml
- "8181" # Rails internal API, see ha-gitlab-internal-api-nodeport.yaml
---
# --- n8n: Allow ingress + Prometheus + intra-namespace ---
@@ -131,6 +131,7 @@ spec:
- operation:
ports:
- "5432"
- "61432" # floating-hostname port, see ha-postgres-peerauth.yaml
---
# --- Grafana: Allow ingress + Prometheus datasource queries + scraping ---
@@ -173,8 +173,22 @@ data:
ttl="24h" >/dev/null
# --- OIDC Auth (Authentik SSO) ---
OIDC_CLIENT_ID="9816a5ae7e7914b5d18f4ab939d011a98f8c8d6b3bb6777c46431afa06ac4a85"
OIDC_CLIENT_SECRET="ed2ba1c6378c7a46341b5162f39a7fab80e37596b01ed387c3719e8e0040344cf1daa307476c2e7a7f75041b3979275b1ebf00bb8bad94c864b4a38ded544f7b"
# OIDC_CLIENT_ID/SECRET used to be hardcoded here in plaintext - found
# and fixed 2026-08-20. Can't source this from Vault itself the way
# every other app's OIDC secret now does (infrastructure/authentik/
# vault-blueprint.yaml, secret/vault-oauth) - this job is what GRANTS
# ESO's own Kubernetes-auth role a few lines above
# (bound_service_account_names="...,external-secrets"), so ESO can't
# yet authenticate to Vault at the point this script runs - genuinely
# circular. Sourced from vault-oidc-bootstrap instead, a plain Secret
# created once manually (kubectl, not git):
# kubectl -n vault create secret generic vault-oidc-bootstrap \
# --from-literal=client_id=<same value as secret/vault-oauth> \
# --from-literal=client_secret=<same value as secret/vault-oauth>
# envFrom on the Job (vault-init-job.yaml) injects these as
# VAULT_OIDC_CLIENT_ID/VAULT_OIDC_CLIENT_SECRET.
OIDC_CLIENT_ID="${VAULT_OIDC_CLIENT_ID:?VAULT_OIDC_CLIENT_ID not set - see vault-oidc-bootstrap secret}"
OIDC_CLIENT_SECRET="${VAULT_OIDC_CLIENT_SECRET:?VAULT_OIDC_CLIENT_SECRET not set - see vault-oidc-bootstrap secret}"
OIDC_DISCOVERY_URL="https://auth.kube.huskypup.net/application/o/vault/"
echo "==> Configuring OIDC auth (Authentik)..."
@@ -16,6 +16,19 @@ spec:
- name: vault-init
image: alpine/k8s:1.32.13
command: ["/bin/bash", "/scripts/vault-init-and-store.sh"]
env:
# See the matching comment in vault-init-configmap.yaml for
# why this can't come from Vault itself via ESO.
- name: VAULT_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: vault-oidc-bootstrap
key: client_id
- name: VAULT_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: vault-oidc-bootstrap
key: client_secret
volumeMounts:
- name: scripts
mountPath: /scripts
@@ -0,0 +1,88 @@
# Multi-site active failover pilot (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - closes the gap
# flagged 2026-08-20: manually kubectl/ssh-copying secrets between home
# and the VPS defeats the point of having Vault at all. This gives the
# VPS its own real Vault -> ESO pipeline instead.
#
# External Secrets Operator itself is installed directly via helm
# (out-of-band, like k3s/cert-manager/Netbird - see
# scripts/vps-bootstrap.sh's existing pattern for why those aren't
# GitOps-managed either):
# helm repo add external-secrets https://charts.external-secrets.io
# helm install external-secrets external-secrets/external-secrets \
# --namespace external-secrets --create-namespace \
# --version 0.20.4 --set installCRDs=true
# (0.20.4 matches home's version - see argocd-apps/infrastructure/
# external-secrets.yaml)
#
# Auth: AppRole, not Kubernetes auth - home's existing vault-backend
# ClusterSecretStore (infrastructure/vault/manifests/clustersecretstore.yaml)
# uses Vault's kubernetes auth method, which verifies a ServiceAccount JWT
# against THAT cluster's own API server - doesn't work for the VPS, it's a
# completely separate k3s cluster with no federation to home's API server.
# AppRole is the standard way to authenticate an external/non-native
# client to Vault instead.
#
# Reachable via https://vault.kube.huskypup.net - a NEW public Cloudflare
# A record (home was previously deliberately kept off the public
# internet - this was an explicit, confirmed decision, not a default).
# Goes through the same Istio ingress gateway that already serves other
# public *.kube.huskypup.net hosts, so no new UniFi port-forward/NodePort
# needed, and no Istio PeerAuthentication/AuthorizationPolicy change
# either - unlike the ha-authentik-postgres NodePort case, this traffic
# arrives already wrapped in a normal in-mesh call from the ingress
# gateway's own identity, not raw external TCP straight to a pod.
#
# NOTE: the AppRole's issued token/secret_id CANNOT be IP-bound
# (token_bound_cidrs / secret_id_bound_cidrs) over this path - confirmed
# live 2026-08-20 that Vault only ever sees the ingress gateway's own pod
# IP for any request arriving this way, never the VPS's real source IP.
# Security boundary here is AppRole credential secrecy + the narrow
# read-only secret/vps/* policy (vps-eso-reader), not network-level
# restriction - this is the standard/expected shape of AppRole auth for
# external clients generally, CIDR-binding is normally extra
# defense-in-depth on top rather than the primary mechanism.
#
# One-time manual bootstrap on Vault's side (already done 2026-08-20, not
# scripted - matches every other Vault policy/auth-method setup in this
# repo, which are also applied by hand via `vault` CLI, not GitOps):
# vault auth enable approle
# vault policy write vps-eso-reader - <<'EOF'
# path "secret/data/vps/*" { capabilities = ["read", "list"] }
# path "secret/metadata/vps/*" { capabilities = ["read", "list"] }
# EOF
# vault write auth/approle/role/vps-eso token_policies="vps-eso-reader" \
# token_ttl=1h token_max_ttl=4h secret_id_num_uses=0
# vault read auth/approle/role/vps-eso/role-id # -> roleId below
# vault write -f auth/approle/role/vps-eso/secret-id # -> secret_id
#
# vault-approle-creds is a plain Secret created manually on the VPS
# (kubectl, not git - same reasoning as every other VPS secret, and
# consistent regardless of "how sensitive" a given field is on its own -
# role_id and secret_id both live in the same Secret, neither in git):
# kubectl -n external-secrets create secret generic vault-approle-creds \
# --from-literal=role_id=<from role-id above> \
# --from-literal=secret_id=<from secret-id above>
# Rotate the secret_id periodically by writing a new one and patching this
# Secret.
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: https://vault.kube.huskypup.net
path: secret # KV v2 mount, same as home's vault-backend
version: v2
auth:
appRole:
path: approle
roleRef:
name: vault-approle-creds
namespace: external-secrets
key: role_id
secretRef:
name: vault-approle-creds
namespace: external-secrets
key: secret_id
@@ -1,6 +1,9 @@
---
# See infrastructure/vps-standby/vault/manifests/ingress.yaml for the
# vps.huskypup.net subdomain design rationale.
# vps.huskypup.net subdomain design rationale, and for why this
# https-redirect Middleware is needed (found live 2026-08-21 diagnosing
# the exact same "Missing auth_url"-shaped symptom on Vault's OIDC login -
# applies equally to every VPS-standby app on this Traefik).
#
# CAVEAT: Authentik's provider/application configs (redirect_uris, etc.)
# are replicated byte-for-byte from home and point at *.kube.huskypup.net
@@ -8,6 +11,16 @@
# won't complete correctly (redirect mismatch) until that's addressed
# separately. Direct/local Authentik admin login still works fine for
# browsing and verifying replicated data.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: authentik
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -15,6 +28,7 @@ metadata:
namespace: authentik
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.middlewares: authentik-https-redirect@kubernetescrd
spec:
ingressClassName: traefik
tls:
@@ -0,0 +1,29 @@
# Multi-site active failover pilot - VPS's half of the Vault pipeline
# (infrastructure/authentik/manifests/ha-postgres-app-pushsecret.yaml has
# home's half, which pushes the authoritative password into Vault at
# secret/vps/pg-authentik-app). Pulls it back down here.
#
# Merge (not Owner/Replace): only overwrites the `password` key, leaving
# CNPG's own generated host/dbname/username fields on this secret intact
# - those are correctly LOCAL to each site (this secret's `host` key
# points at the VPS's own local -rw service, which CNPG itself still
# needs internally, even though the app tier reads a different value via
# the pg-authentik.ha.huskypup.net override in values.yaml).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: pg-authentik-app-password
namespace: authentik
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: pg-authentik-app
creationPolicy: Merge
data:
- secretKey: password
remoteRef:
key: vps/pg-authentik-app
property: password
@@ -34,9 +34,12 @@ global:
envFrom:
- secretRef:
name: authentik
- secretRef:
name: pg-authentik-app
prefix: AUTHENTIK_POSTGRESQL__
# The pg-authentik-app secretRef that used to live here is GONE - see
# the matching comment in infrastructure/authentik/values.yaml (home's
# copy of this same block) for why: its lowercase-suffixed env vars
# (AUTHENTIK_POSTGRESQL__host etc.) were silently winning over the
# explicit uppercase HOST/PORT overrides below, connecting straight to
# the local CNPG service the whole time regardless of what was set.
env:
- name: AUTHENTIK_URL
value: "https://auth.vps.huskypup.net"
@@ -1,6 +1,18 @@
---
# See infrastructure/vps-standby/vault/manifests/ingress.yaml for the
# vps.huskypup.net subdomain design rationale.
# vps.huskypup.net subdomain design rationale, and for why this
# https-redirect Middleware is needed (found live 2026-08-21 diagnosing
# the exact same symptom on this app's OIDC login).
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: gitea
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -8,6 +20,7 @@ metadata:
namespace: gitea
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.middlewares: gitea-https-redirect@kubernetescrd
spec:
ingressClassName: traefik
tls:
@@ -0,0 +1,144 @@
---
# GitLab cross-site replication Phase 1c (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - was a fully
# independent standalone cluster (own initdb, own root/Scooby test data,
# never meant to survive this). Now a real replica of home's pg-gitlab
# (apps/gitlab/manifests/cnpg-cluster.yaml), matching the pattern already
# proven for pg-authentik.
#
# Deployed in two stages, same as the original authentik/n8n/nextcloud
# VPS replicas were: first as a plain bootstrap.recovery replica (this
# commit - no externalClusters/replica streaming config yet, since that
# needs THIS cluster's own CNPG-generated certs to exist first, which
# only happens once the cluster itself exists), then a second commit adds
# the streaming replication config once those certs have been copied
# cross-cluster.
#
# postgresql.parameters MUST match-or-exceed home's - copied verbatim
# from apps/gitlab/manifests/cnpg-cluster.yaml. A replica can't replay
# WAL past a checkpoint recorded with higher settings than its own
# ("recovery aborted because of insufficient parameter settings",
# confirmed live for pg-authentik).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-gitlab
namespace: gitlab
spec:
imageName: ghcr.io/cloudnative-pg/postgresql:16
instances: 1
# shared_buffers 1GB below needs headroom - matches home's requests/limits.
resources:
requests:
memory: "1Gi"
cpu: "50m"
limits:
memory: "4Gi"
cpu: "500m"
storage:
size: 50Gi # matches home's size
storageClass: local-path
primaryUpdateStrategy: unsupervised
postgresql:
parameters:
max_connections: "400"
shared_buffers: "1GB"
effective_cache_size: "3GB"
maintenance_work_mem: "256MB"
checkpoint_completion_target: "0.9"
wal_buffers: "16MB"
default_statistics_target: "100"
random_page_cost: "1.1"
effective_io_concurrency: "200"
work_mem: "16MB"
min_wal_size: "1GB"
max_wal_size: "4GB"
max_worker_processes: "4"
max_parallel_workers_per_gather: "2"
max_parallel_workers: "4"
bootstrap:
recovery:
source: home
# Stage 2 (see /home/scooby/.claude/plans/jiggly-snacking-iverson.md):
# connectionParameters added alongside the existing barmanObjectStore
# on the SAME "home" entry - the recovery-mode initial seed stays
# exactly as-is, ongoing replication now streams instead of relying on
# WAL-archive polling (the source of the crash-looping/probe-tuning
# fights documented for the pg-authentik precedent). Also needs a
# self-referencing "vps" entry - replica.self must match an
# externalClusters name too, even though it's this very cluster
# (confirmed live: "External cluster vps not found" until one was
# added).
#
# Reachable via home.kube.huskypup.net:61442 - a UniFi WAN port-forward
# to talos-cp-01's real LAN IP, source-IP-restricted to the VPS/witness
# public IPs (see apps/gitlab/manifests/ha-postgres-nodeport.yaml). NOT
# the Netbird mesh directly - UniFi has no route to that CIDR
# (confirmed live for the pg-authentik precedent).
externalClusters:
- name: home
barmanObjectStore:
# serverName defaults to the externalClusters[].name, NOT the
# source Postgres cluster's actual metadata.name - home's own
# backup: block defaults serverName to "pg-gitlab" (its own
# name). Without this override, recovery looks under the wrong
# server-name prefix and finds nothing (confirmed live for the
# pg-authentik precedent).
serverName: pg-gitlab
destinationPath: s3://cnpg-backups/pg-gitlab
endpointURL: http://vps-minio.minio.svc.cluster.local:9000
s3Credentials:
accessKeyId:
name: vps-minio-secret
key: accesskey
secretAccessKey:
name: vps-minio-secret
key: secretkey
connectionParameters:
host: home.kube.huskypup.net
port: "61442"
dbname: gitlabhq_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-gitlab-home-replication
key: tls.crt
sslKey:
name: pg-gitlab-home-replication
key: tls.key
sslRootCert:
name: pg-gitlab-home-ca
key: ca.crt
- name: vps
connectionParameters:
host: pg-gitlab-rw
port: "5432"
dbname: gitlabhq_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-gitlab-replication
key: tls.crt
sslKey:
name: pg-gitlab-replication
key: tls.key
sslRootCert:
name: pg-gitlab-ca
key: ca.crt
# Distributed topology: both sides agree home is primary today. No
# replica.enabled - mutually exclusive with primary/self (confirmed
# live for the pg-authentik precedent).
replica:
self: vps
primary: home
source: home
monitoring:
enablePodMonitor: false # no Prometheus on the VPS cluster
@@ -0,0 +1,87 @@
# GitLab cross-site replication Phase 1b - VPS's half of the Vault
# pipeline (apps/gitlab/manifests/ha-gitlab-secrets-pushsecret.yaml has
# home's half, which pushes the authoritative values into Vault at
# secret/vps/gitlab-{rails,gitlab-shell,gitaly,praefect}-secret). Pulls
# them back down here, BEFORE Phase 1c's Postgres replication goes live.
#
# creationPolicy: Owner (not Merge) - each of these secrets has exactly
# one key with nothing else to preserve, unlike pg-authentik-app which
# has CNPG-local host/dbname/username fields alongside the shared
# password. The chart's shared-secrets job skips creating a key that
# already exists, so having ESO own these outright is sufficient to stop
# the VPS from ever generating its own divergent values.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-rails-secret
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: gitlab-rails-secret
creationPolicy: Owner
data:
- secretKey: secrets.yml
remoteRef:
key: vps/gitlab-rails-secret
property: secrets.yml
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-gitlab-shell-secret
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: gitlab-gitlab-shell-secret
creationPolicy: Owner
data:
- secretKey: secret
remoteRef:
key: vps/gitlab-gitlab-shell-secret
property: secret
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-gitaly-secret
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: gitlab-gitaly-secret
creationPolicy: Owner
data:
- secretKey: token
remoteRef:
key: vps/gitlab-gitaly-secret
property: token
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-praefect-secret
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: gitlab-praefect-secret
creationPolicy: Owner
data:
- secretKey: token
remoteRef:
key: vps/gitlab-praefect-secret
property: token
@@ -0,0 +1,62 @@
# GitLab cross-site replication - VPS's half of the Vault pipeline
# (apps/gitlab/manifests/ha-postgres-app-pushsecret.yaml has home's half).
# Pulls the authoritative app-role passwords back down here.
#
# creationPolicy REVERTED Merge -> Owner 2026-08-22: Merge assumed CNPG
# itself would generate a base pg-gitlab-app/pg-praefect-app secret
# (host/dbname/username keys) for this ExternalSecret to merge a
# password into - true for a normal owning/primary Cluster, but WRONG
# for these VPS Clusters since Phase 1c's recreate: `pg-gitlab`/
# `pg-praefect` here are bootstrap.recovery REPLICA clusters
# (replica.self: vps) - the `app` role is part of the replicated
# global role/password state streamed from home's primary, so CNPG
# correctly never creates a local owner secret for it here. Merge had
# nothing to merge onto -> ExternalSecret condition
# "SecretMissing/CreationPolicy=Merge" -> pg-gitlab-app never existed
# -> vps-gitlab-webservice crash-looped on
# ActiveRecord::DatabaseConnectionError for ~39h before this was
# caught (confirmed live, unrelated to any same-day change). Owner:
# the ExternalSecret creates the whole secret itself. This chart only
# ever reads the `password` key from it - host/port/dbname/username
# are hardcoded directly in infrastructure/vps-standby/gitlab/
# values.yaml's global.psql/global.praefect.psql blocks, not read from
# this secret's other keys, so a password-only Owner-created secret is
# sufficient (confirmed live: no other consumer of this secret's keys
# found).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: pg-gitlab-app-password
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: pg-gitlab-app
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: vps/pg-gitlab-app
property: password
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: pg-praefect-app-password
namespace: gitlab
spec:
refreshInterval: 5m
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: pg-praefect-app
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: vps/pg-praefect-app
property: password
@@ -0,0 +1,47 @@
---
# Exposes the VPS's pg-gitlab/pg-praefect primaries (replicas today) back
# to home over the Netbird mesh, for GitLab cross-site replication (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - needed for
# failback (home rejoining as a replica once it's back). Same pattern as
# infrastructure/vps-standby/authentik/manifests/ha-postgres-nodeport.yaml.
#
# No UniFi port-forward or Kyverno PolicyException needed here - the
# VPS's k3s has no NodePort restriction, and home reaching OUT to the VPS
# already works today via the netbird-egress DaemonSet's route (confirmed
# live all session for MinIO/CrowdSec/pg-authentik) - it's only the
# reverse direction (external peers reaching INTO home) that needs the
# UniFi workaround (see apps/gitlab/manifests/ha-postgres-nodeport.yaml).
#
# Different nodePort numbers than home's side (32442/32443) - not
# required since these are different machines, but matches the
# authentik precedent's convention of keeping each site's numbers
# distinct to avoid confusion.
apiVersion: v1
kind: Service
metadata:
name: ha-gitlab-postgres
namespace: gitlab
spec:
type: NodePort
selector:
cnpg.io/cluster: pg-gitlab
cnpg.io/instanceRole: primary
ports:
- port: 5432
targetPort: 5432
nodePort: 32444
---
apiVersion: v1
kind: Service
metadata:
name: ha-praefect-postgres
namespace: gitlab
spec:
type: NodePort
selector:
cnpg.io/cluster: pg-praefect
cnpg.io/instanceRole: primary
ports:
- port: 5432
targetPort: 5432
nodePort: 32445
@@ -0,0 +1,42 @@
---
# Real GitLab on the VPS. https-redirect Middleware from the start this
# time - see infrastructure/vps-standby/vault/manifests/ingress.yaml for
# why it's needed (found live 2026-08-21: Traefik serves plain HTTP
# alongside HTTPS unless explicitly redirected, which silently breaks
# OIDC login since the callback URL computed client-side won't match
# what's registered in Authentik).
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: gitlab
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vps-gitlab
namespace: gitlab
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.middlewares: gitlab-https-redirect@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- gitlab.vps.huskypup.net
secretName: vps-gitlab-tls
rules:
- host: gitlab.vps.huskypup.net
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vps-gitlab-webservice-default
port:
number: 8181
@@ -0,0 +1,59 @@
# Periodic git mirror: home's Scooby/Homelabv4 (the actual GitOps
# source) -> VPS GitLab's copy of the same project. See
# mirror-sync-secret.yaml for why this is a plain CronJob instead of
# GitLab's native pull-mirror feature (Premium-gated, confirmed live
# 2026-08-21). This is the VPS-standby equivalent of the original
# plan's "Gitea pull mirror of the GitOps source" - same intent,
# different mechanism now that real GitLab replaced Gitea here.
apiVersion: batch/v1
kind: CronJob
metadata:
name: gitlab-mirror-sync
namespace: gitlab
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 600
template:
spec:
restartPolicy: Never
containers:
- name: mirror-sync
image: alpine/git:2.43.0
command:
- sh
- -c
- |
set -eu
rm -rf /tmp/mirror.git
git clone --mirror "https://${PULL_USERNAME}:${PULL_TOKEN}@gitlab.kube.huskypup.net/Scooby/Homelabv4.git" /tmp/mirror.git
cd /tmp/mirror.git
git push --mirror "https://root:${PUSH_TOKEN}@gitlab.vps.huskypup.net/Scooby/Homelabv4.git"
echo "Mirror sync complete: $(date -u)"
env:
- name: PULL_USERNAME
valueFrom:
secretKeyRef:
name: gitlab-mirror-sync-secret
key: pull_username
- name: PULL_TOKEN
valueFrom:
secretKeyRef:
name: gitlab-mirror-sync-secret
key: pull_token
- name: PUSH_TOKEN
valueFrom:
secretKeyRef:
name: gitlab-mirror-sync-secret
key: push_token
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
memory: 512Mi
@@ -0,0 +1,39 @@
# Credentials for mirror-sync-cronjob.yaml. GitLab's native repository
# mirroring (pull mirror) is a Premium/Ultimate-gated feature as of this
# chart version - confirmed live 2026-08-21: the API rejects `mirror`
# and `import_url` as valid params on this CE instance entirely
# ("allow_merge_on_skipped_pipeline, ... are missing" - i.e. neither
# field is even recognized). So this CronJob does the plain-git
# equivalent (clone --mirror / push --mirror) on a schedule instead.
#
# - pull_username/pull_token: read-only deploy token on HOME's
# Scooby/Homelabv4 project (deploy tokens can't have write_repository
# - it's not a valid scope for them, confirmed live).
# - push_username/push_token: a write_repository-scoped Personal Access
# Token for VPS GitLab's root user (deploy tokens can't push either).
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-mirror-sync
namespace: gitlab
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: gitlab-mirror-sync-secret
creationPolicy: Owner
data:
- secretKey: pull_username
remoteRef:
key: vps/gitlab-mirror
property: username
- secretKey: pull_token
remoteRef:
key: vps/gitlab-mirror
property: token
- secretKey: push_token
remoteRef:
key: vps/gitlab-mirror
property: push_token
@@ -0,0 +1,61 @@
# Shares the same Authentik OAuth2 provider/client_id as home's GitLab
# (infrastructure/authentik/gitlab-blueprint.yaml has both redirect_uris
# registered). Same Vault path (secret/gitlab-oauth) home's copy reads
# from - vps-eso-reader policy widened to allow this one extra path
# (infrastructure/vps-eso/manifests/clustersecretstore.yaml's policy,
# applied live via vault CLI, not git-tracked - matches how every other
# Vault policy/auth-method in this repo is set up).
#
# `provider` key added 2026-08-21: found live that GITLAB_OMNIBUS_CONFIG
# (this app's values.yaml extraEnv) is an omnibus-image-only convention
# the CNG webservice image never processes - see the matching comment
# in apps/gitlab/manifests/external-secret-oidc.yaml (home) for the
# full story. The real mechanism needs this key to hold a whole
# YAML-encoded provider block, not raw client_id/secret strings.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: gitlab-oidc
namespace: gitlab
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: gitlab-oidc-secret
creationPolicy: Owner
template:
type: Opaque
data:
GITLAB_OIDC_CLIENT_ID: "{{ .clientId }}"
GITLAB_OIDC_CLIENT_SECRET: "{{ .clientSecret }}"
provider: |
name: openid_connect
label: Authentik
args:
name: openid_connect
scope:
- openid
- profile
- email
response_type: code
issuer: 'https://auth.kube.huskypup.net/application/o/gitlab/'
discovery: true
client_auth_method: query
uid_field: sub
send_scope_to_token_endpoint: true
pkce: true
client_options:
identifier: '{{ .clientId }}'
secret: '{{ .clientSecret }}'
redirect_uri: 'https://gitlab.vps.huskypup.net/users/auth/openid_connect/callback'
data:
- secretKey: clientId
remoteRef:
key: gitlab-oauth
property: client-id
- secretKey: clientSecret
remoteRef:
key: gitlab-oauth
property: client-secret
@@ -0,0 +1,108 @@
---
# GitLab cross-site replication Phase 1c (see
# /home/scooby/.claude/plans/jiggly-snacking-iverson.md) - the VPS never
# had a pg-praefect cluster before (VPS ran Gitaly without Praefect,
# global.praefect.enabled: false). This is new, matching home's
# apps/gitlab/manifests/praefect-cnpg-cluster.yaml as a real replica.
#
# Same two-stage deployment as cnpg-cluster.yaml in this directory: this
# commit is bootstrap.recovery only, streaming config added once this
# cluster's own certs exist to copy cross-cluster.
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: pg-praefect
namespace: gitlab
spec:
imageName: ghcr.io/cloudnative-pg/postgresql:16
instances: 1
resources:
requests:
memory: "512Mi"
cpu: "50m"
limits:
memory: "2Gi"
storage:
size: 10Gi # matches home's size
storageClass: local-path
primaryUpdateStrategy: unsupervised
# Must match-or-exceed home's - copied verbatim from
# apps/gitlab/manifests/praefect-cnpg-cluster.yaml.
postgresql:
parameters:
max_connections: "200"
shared_buffers: "512MB"
effective_cache_size: "1536MB"
maintenance_work_mem: "128MB"
checkpoint_completion_target: "0.9"
wal_buffers: "16MB"
default_statistics_target: "100"
random_page_cost: "1.1"
effective_io_concurrency: "200"
work_mem: "4MB"
min_wal_size: "1GB"
max_wal_size: "4GB"
max_worker_processes: "4"
bootstrap:
recovery:
source: home
# Stage 2 - same pattern as cnpg-cluster.yaml (pg-gitlab) in this same
# directory.
externalClusters:
- name: home
barmanObjectStore:
serverName: pg-praefect
destinationPath: s3://cnpg-backups/pg-praefect
endpointURL: http://vps-minio.minio.svc.cluster.local:9000
s3Credentials:
accessKeyId:
name: vps-minio-secret
key: accesskey
secretAccessKey:
name: vps-minio-secret
key: secretkey
connectionParameters:
host: home.kube.huskypup.net
port: "61443"
dbname: praefect_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-praefect-home-replication
key: tls.crt
sslKey:
name: pg-praefect-home-replication
key: tls.key
sslRootCert:
name: pg-praefect-home-ca
key: ca.crt
- name: vps
connectionParameters:
host: pg-praefect-rw
port: "5432"
dbname: praefect_production
user: streaming_replica
sslmode: verify-ca
sslCert:
name: pg-praefect-replication
key: tls.crt
sslKey:
name: pg-praefect-replication
key: tls.key
sslRootCert:
name: pg-praefect-ca
key: ca.crt
replica:
self: vps
primary: home
source: home
monitoring:
enablePodMonitor: false # no Prometheus on the VPS cluster
@@ -0,0 +1,19 @@
# Same fix as apps/gitlab/manifests/redis-init-fix.yaml (home) - GitLab
# chart 7.7.0's initializer ordering bug: 7_prometheus_metrics.rb loads
# before 7_redis.rb (alphabetical 'p' < 'r'), triggering Redis cache
# access before Gitlab::Redis::ALL_CLASSES is defined. This ConfigMap
# provides an early initializer that loads the Redis module first.
#
# Mounting this into webservice/sidekiq deployments still has to be a
# manual kubectl patch after first deploy - chart 7.7.0 doesn't support
# extraVolumes on those workloads, same limitation home hit, and home's
# own patch was never git-tracked either (applied live). See this
# directory's own deploy notes for the exact patch commands.
apiVersion: v1
kind: ConfigMap
metadata:
name: redis-init-fix
namespace: gitlab
data:
0_redis_early.rb: |
require "gitlab/redis"
@@ -0,0 +1,22 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: redis-gitlab
namespace: gitlab
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: redis-gitlab-secret
creationPolicy: Owner
template:
type: Opaque
data:
password: "{{ .password }}"
data:
- secretKey: password
remoteRef:
key: vps/gitlab-redis
property: password
@@ -0,0 +1,84 @@
# Real GitLab on the VPS - standalone Redis, same pattern as home's
# apps/gitlab/manifests/redis-cluster.yaml (despite that file's name,
# it's already just a single standalone instance - "Sentinel" in the
# filename is legacy, not what's actually running). local-path storage
# instead of rook-ceph-block.
---
apiVersion: v1
kind: Service
metadata:
name: redis-gitlab
namespace: gitlab
spec:
ports:
- port: 6379
targetPort: 6379
name: redis
selector:
app: redis-gitlab
type: ClusterIP
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis-gitlab
namespace: gitlab
spec:
serviceName: redis-gitlab
replicas: 1
selector:
matchLabels:
app: redis-gitlab
template:
metadata:
labels:
app: redis-gitlab
spec:
securityContext:
fsGroup: 1000
containers:
- name: redis
image: redis:7.0-alpine
securityContext:
runAsUser: 999
runAsGroup: 1000
runAsNonRoot: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
ports:
- containerPort: 6379
name: redis
command:
- sh
- -c
- redis-server --appendonly yes --requirepass "$REDIS_PASSWORD"
env:
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: redis-gitlab-secret
key: password
resources:
requests:
cpu: 25m
memory: 128Mi
limits:
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
- name: tmp
mountPath: /tmp
volumes:
- name: tmp
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi
@@ -0,0 +1,275 @@
# Real GitLab on the VPS - simplified single-node equivalent of
# apps/gitlab/values.yaml (home). Same chart, same version, same
# Authentik OIDC provider/client (shared, see infrastructure/authentik/
# gitlab-blueprint.yaml). Deliberately lighter than home's: no Praefect
# (pointless with 1 Gitaly node), no PgBouncer (direct CNPG connection,
# low enough traffic not to need pooling), no SAML, no Container
# Registry, no KAS - all addable later if actually needed, matches the
# "keep it lighter" decision already made for the rest of vps-standby.
global:
hosts:
domain: vps.huskypup.net
gitlab:
name: gitlab.vps.huskypup.net
# Ingress disabled - own Ingress + Traefik https-redirect Middleware
# handle routing (infrastructure/vps-standby/gitlab/manifests/ingress.yaml)
ingress:
enabled: false
configureCertmanager: false
edition: ce
time_zone: UTC
email:
from: 'gitlab@vps.huskypup.net'
display_name: GitLab (VPS)
reply_to: 'noreply@vps.huskypup.net'
# External PostgreSQL via CNPG (infrastructure/vps-standby/gitlab/manifests/cnpg-cluster.yaml)
psql:
host: pg-gitlab-rw.gitlab.svc.cluster.local
port: 5432
database: gitlabhq_production
username: app
password:
secret: pg-gitlab-app
key: password
# gitlab.kas.enabled: false alone doesn't stop KAS pods deploying -
# there's a SEPARATE global.kas.enabled toggle (default true) that
# actually controls it, found live 2026-08-21.
kas:
enabled: false
# Single Gitaly node, no Praefect - HA routing is pointless with 1 node
gitaly:
enabled: true
internal:
names:
- default
external: []
praefect:
enabled: false
# Standalone Redis (infrastructure/vps-standby/gitlab/manifests/redis.yaml)
redis:
host: redis-gitlab.gitlab.svc.cluster.local
port: 6379
auth:
enabled: true
secret: redis-gitlab-secret
key: password
appConfig:
# CORRECTED 2026-08-21 (see apps/gitlab/values.yaml (home) for the
# full story) - `providers:` list restored, now pointing at
# gitlab-oidc-secret's `provider` key which holds a real
# YAML-encoded provider block (manifests/oidc-secret.yaml), the
# actual mechanism the CNG webservice image processes.
# GITLAB_OMNIBUS_CONFIG below is an omnibus-image-only convention
# this image never reads - always a no-op here.
omniauth:
enabled: true
allowSingleSignOn: ['openid_connect']
blockAutoCreatedUsers: false
autoLinkUser: ['openid_connect']
syncProfileFromProvider: ['openid_connect']
syncProfileAttributes: ['email', 'name']
providers:
- secret: gitlab-oidc-secret
key: provider
# GitLab's pre-upgrade hook checks the deployed version against a
# previous-release ConfigMap to enforce supported upgrade paths - found
# live 2026-08-21 that it fails with "unsupported upgrade path" on a
# genuinely FRESH install too (confirmed: no prior chart-info ConfigMap,
# no Helm release secret, brand new namespace) - a chart/ArgoCD
# interaction quirk (ArgoCD doesn't do real `helm install`, so the
# optional chart-info ConfigMap's absence apparently isn't handled the
# way the script expects). Not relevant for a fresh install anyway - only
# matters for real version-to-version upgrades, which is a future concern
# once this is actually running something to upgrade FROM.
upgradeCheck:
enabled: false
postgresql:
install: false
redis:
install: false
pgbouncer:
enabled: false
# GitLab's own bundled MinIO for object storage (uploads, artifacts) -
# separate from vps-minio (which backs CNPG WAL archiving for other apps)
minio:
persistence:
storageClass: local-path
size: 20Gi
resources:
requests:
cpu: 25m
memory: 256Mi
limits:
memory: 1Gi
registry:
enabled: false
gitlab:
webservice:
minReplicas: 1
maxReplicas: 1
# 1 worker process, not 2 - single-node VPS, already sharing the box
# with several other unrelated services (barman, k3s itself, node/
# gunicorn apps) that keep it chronically CPU-overcommitted (load
# average ~22-26 sustained on 6 cores, confirmed live 2026-08-21,
# unrelated to GitLab). Less to fork/boot per pod start.
workerProcesses: 1
deployment:
# Recreate, not the chart default RollingUpdate - with only 1
# replica, RollingUpdate still surges an extra pod during every
# rollout, and this box can't handle 2 concurrent GitLab Ruby
# boots (confirmed live 2026-08-21: load average spiked past 39
# on 6 cores, each pod starving the other's boot until both got
# liveness-killed - a feedback loop that never converges). No
# real availability cost since there's only 1 replica anyway.
strategy:
type: Recreate
# Chart defaults (initialDelaySeconds:20, periodSeconds:60,
# failureThreshold:3 => ~200s grace) aren't enough on this
# contended box - confirmed live 2026-08-21 the webservice
# (puma) container was repeatedly SIGTERM'd by the liveness
# probe mid-`require` (still loading gems via bootsnap), never
# finishing boot, restarting from zero every time. Loosened so
# a slow boot survives instead of getting killed and restarted
# forever.
livenessProbe:
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 30
successThreshold: 1
failureThreshold: 15
extraEnvFrom:
GITLAB_OIDC_CLIENT_ID:
secretKeyRef:
name: gitlab-oidc-secret
key: GITLAB_OIDC_CLIENT_ID
GITLAB_OIDC_CLIENT_SECRET:
secretKeyRef:
name: gitlab-oidc-secret
key: GITLAB_OIDC_CLIENT_SECRET
extraEnv:
GITLAB_OMNIBUS_CONFIG: |
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_auto_link_user'] = ['openid_connect']
gitlab_rails['omniauth_sync_profile_from_provider'] = ['openid_connect']
gitlab_rails['omniauth_sync_profile_attributes'] = ['email', 'name']
gitlab_rails['omniauth_providers'] = [
{
'name' => 'openid_connect',
'label' => 'Authentik',
'args' => {
'name' => 'openid_connect',
'scope' => ['openid', 'profile', 'email'],
'response_type' => 'code',
'issuer' => 'https://auth.kube.huskypup.net/application/o/gitlab/',
'discovery' => true,
'client_auth_method' => 'query',
'uid_field' => 'sub',
'send_scope_to_token_endpoint' => true,
'pkce' => true,
'client_options' => {
'identifier' => ENV['GITLAB_OIDC_CLIENT_ID'],
'secret' => ENV['GITLAB_OIDC_CLIENT_SECRET'],
'redirect_uri' => 'https://gitlab.vps.huskypup.net/users/auth/openid_connect/callback'
}
}
}
]
resources:
requests:
cpu: 150m
memory: 1536Mi
limits:
memory: 3Gi
kas:
enabled: false
gitaly:
persistence:
storageClass: local-path
size: 30Gi
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
gitlab-runner:
install: false
gitlab-exporter:
enabled: true
metrics:
enabled: false # no Prometheus on the VPS cluster
sidekiq:
minReplicas: 1
maxReplicas: 1
# Recreate, not RollingUpdate - see gitlab.webservice.deployment.
# strategy above for why.
strategy:
type: Recreate
# Same probe-loosening as webservice.deployment above, same root
# cause (confirmed live 2026-08-21) - sidekiq's own boot was
# getting SIGTERM'd mid-`require` by the liveness probe on this
# CPU-contended box, every ~90-150s, resetting to zero each time
# and never actually finishing.
# Loosened further 2026-08-21: even with the first pass above, 3
# consecutive attempts all reached "Booting Sidekiq" (the real
# engine actually starts, confirmed via "Listening on queues" in
# logs) but never opened the health-check port (3808) within the
# ~8min grace window - the box's ambient background load (gitea,
# restic backup, barman, k3s itself - all unrelated to GitLab, load
# average sustained 35-55 on 6 cores) is apparently severe enough to
# starve that specific thread's CPU scheduling even after the main
# process is otherwise up. Genuine forward progress each time, not
# a hard hang, so more patience rather than a code fix.
livenessProbe:
initialDelaySeconds: 120
periodSeconds: 30
timeoutSeconds: 30
successThreshold: 1
failureThreshold: 30
resources:
requests:
cpu: 25m
memory: 384Mi
limits:
cpu: 250m
memory: 1536Mi
certmanager:
install: false
installCRDs: false
prometheus:
install: false
nginx-ingress:
enabled: false
gitlab-runner:
install: false
@@ -1,6 +1,18 @@
---
# See infrastructure/vps-standby/vault/manifests/ingress.yaml for the
# vps.huskypup.net subdomain design rationale.
# vps.huskypup.net subdomain design rationale, and for why this
# https-redirect Middleware is needed (found live 2026-08-21 diagnosing
# the exact same symptom on this app's OIDC login).
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: n8n
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -8,6 +20,7 @@ metadata:
namespace: n8n
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.middlewares: n8n-https-redirect@kubernetescrd
spec:
ingressClassName: traefik
tls:
@@ -1,6 +1,18 @@
---
# See infrastructure/vps-standby/vault/manifests/ingress.yaml for the
# vps.huskypup.net subdomain design rationale.
# vps.huskypup.net subdomain design rationale, and for why this
# https-redirect Middleware is needed (found live 2026-08-21 diagnosing
# the exact same symptom on this app's OIDC login).
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: nextcloud
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -8,6 +20,7 @@ metadata:
namespace: nextcloud
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.middlewares: nextcloud-https-redirect@kubernetescrd
spec:
ingressClassName: traefik
tls:
@@ -16,8 +16,20 @@
# kubectl -n nextcloud create secret generic nextcloud-restic-password \
# --from-literal=password=<home's nextcloud-restic-password>
#
# Nextcloud itself isn't running (replicas: 0, see values.yaml) so there's
# no live-write conflict risk overwriting the PVC on every run.
# CORRECTED 2026-08-22: the "replicas: 0" assumption below was wrong -
# confirmed live vps-nextcloud actually runs continuously at replicas: 1
# (no replicas key was ever set in values.yaml; the chart's own default
# applies). This job had been silently failing every run for 2+ days as
# a result: restic couldn't overwrite the live pod's config.php (mode
# 0640, owned by www-data/33; this job runs as UID 65534 with only
# group 33 via fsGroup, so it can read but not write that one file) -
# "ignoring error for /data/config/config.php: permission denied",
# Fatal: 1 error, even though the other 31000+ files/15GiB restored
# fine every time. Fixed by excluding config.php from the restore
# below rather than chasing permissions - overwriting a live, running
# app's own config.php from a background restore job is the wrong
# move regardless (it holds this site's actual current db credentials,
# etc.), not just a permissions bug to route around.
apiVersion: batch/v1
kind: CronJob
metadata:
@@ -64,7 +76,7 @@ spec:
fi
echo "==> Restoring latest snapshot into /data..."
restic restore latest --tag nextcloud --host nextcloud-k8s --target /
restic restore latest --tag nextcloud --host nextcloud-k8s --target / --exclude /data/config/config.php
echo "==> Done."
env:
@@ -4,6 +4,29 @@
# (vault.vps.huskypup.net) that doesn't depend on home being up at all -
# see the vps.huskypup.net subdomain design note in
# infrastructure/vps-standby/cert-manager/manifests/cluster-issuer.yaml.
#
# https-redirect Middleware: found live 2026-08-21 that this Ingress's
# tls: block makes HTTPS available but does NOT stop Traefik from ALSO
# serving plain HTTP on the same host - a browser landing on
# http://vault.vps.huskypup.net (no redirect) gets a Vault UI that
# computes its OIDC callback URL using window.location.origin, i.e.
# http://..., which doesn't match the https:// entry registered in
# allowed_redirect_uris (auth/oidc/role/default) - Vault silently returns
# an empty auth_url ("Missing auth_url" in the UI) rather than an
# obviously-DNS/config-looking error. Applies to every VPS-standby app on
# this same Traefik, not just Vault (same fix needed and applied in each
# of infrastructure/vps-standby/{gitea,n8n,nextcloud,authentik}/manifests/
# ingress.yaml too).
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: vault
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
@@ -11,6 +34,7 @@ metadata:
namespace: vault
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.middlewares: vault-https-redirect@kubernetescrd
spec:
ingressClassName: traefik
tls: