Documents the Pangolin + pangolin-kube-controller deployment: architecture, per-file component map, the two SSO flows (dashboard login vs downstream resource protection), the ingress-protection request flow, a step-by-step runbook for adding a new protected ingress, troubleshooting rooted in the DEV-457 findings, and hard "do not delete" notes on the two PVCs, the sealed secrets, and the Traefik HelmChartConfig that Pangolin depends on. Co-Authored-By: Paperclip <noreply@paperclip.ing>
13 KiB
Pangolin
Pangolin is BasicStack's self-hosted reverse proxy and SSO gateway. It sits in front of
selected cluster services (e.g. paperclip.basicstack.de) and forces every request through
Pocket-ID before it reaches the backend. Pangolin does not terminate TLS or handle the
raw HTTP request path itself — it publishes a Traefik dynamic config, and the
pangolin-kube-controller translates that config into Traefik CRDs that our existing
Traefik ingress controller enforces.
Public URL: https://pangolin.basicstack.de — App source: https://github.com/fosrl/pangolin
Architecture
Browser ──HTTPS──► Traefik (ns=kube-system)
│
│ IngressRoute + Middleware (badger) + TraefikService
│ ▲ managed by pangolin-kube-controller (ns=pangolin)
│ │
│ │ polls /api/v1/traefik-config every ~15s
│ │
▼ │
Pangolin API (ns=pangolin)
├─ port 3000 external dashboard API (session auth, behind Ingress)
├─ port 3001 internal traefik-config API (Bearer auth, cluster-internal only)
└─ port 3002 UI HTTP (behind Ingress)
│
├── PostgreSQL (ns=pangolin, pangolin-postgres)
└── OIDC provider: Pocket-ID at https://auth.basicstack.de
Auth flow for a protected resource (e.g. paperclip.basicstack.de):
Browser ──► Traefik ──► badger middleware ──callback──► Pangolin API (3001)
│
▼
Pocket-ID / session
Two long-running workloads live in the pangolin namespace:
- Pangolin — the app itself: dashboard, resource/target CRUD, OIDC handshake,
session cookies, and the internal
/api/v1/traefik-configendpoint that publishes the current desired Traefik state. - pangolin-kube-controller — a Kubernetes controller (leader-elected) that
polls the Pangolin API, then upserts Traefik
IngressRoute,Middleware,TraefikService, etc. via server-side apply into thepangolinnamespace.
The controller only writes CRDs into pangolin; the backing Service typically
lives in the target app's own namespace. Traefik is started with
--providers.kubernetescrd.allowCrossNamespace=true so those cross-namespace
refs resolve.
Components (files in this directory)
| File | Purpose |
|---|---|
namespace.yaml |
Creates the pangolin namespace. |
pangolin-config.yaml |
Pangolin config.yml template (ConfigMap). __DATABASE_URL__ is rendered at startup by the render-config init container from the sealed secret. |
pangolin-deployment.yaml |
Pangolin app Deployment, Service (ports 3000/3001/3002), Ingress, and the pangolin-data PVC. |
pangolin-secrets-sealed.yaml |
Sealed OIDC client id/secret and local admin password. |
pangolin-postgres-secrets-sealed.yaml |
Sealed Postgres credentials plus the rendered DATABASE_URL. |
postgres.yaml |
Single-replica Postgres 17.5 + pangolin-postgres-data PVC. |
pangolin-controller-config.yaml |
pangolin-kube-controller env: CONFIG_ENDPOINT, target namespace, log level. |
pangolin-controller-deployment.yaml |
Controller Deployment (image fosrl/pangolin-kube-controller:0.1.0-alpha.1). |
pangolin-controller-rbac.yaml |
ServiceAccount + ClusterRole/Binding: CRUD on Traefik CRDs and leases. |
pangolin-controller-service.yaml |
ClusterIP for controller metrics. |
pangolin-controller-api-key-sealed.yaml |
Sealed Authorization header the controller sends to the internal API. |
ArgoCD Application: apps/app-pangolin.yaml — sync policy is
automated: { prune: true, selfHeal: true }. Any local edit must land in git.
SSO integration (Pocket-ID)
Pangolin authenticates users against Pocket-ID via OIDC. Two independent SSO flows exist and should not be confused:
- Logging into the Pangolin dashboard. Configured in
pangolin-config.yaml(oidc.*) and via env inpangolin-deployment.yaml:- Issuer:
https://auth.basicstack.de - Callback URL:
https://pangolin.basicstack.de/auth/callback - Scopes:
openid profile email groups - Client id/secret:
pangolin-secretssealed secret keysoidc-client-id/oidc-client-secret.
- Issuer:
- Protecting a downstream resource (e.g. paperclip.basicstack.de). Handled by the badger Traefik middleware that the controller emits. The middleware calls back into Pangolin's internal API (port 3001) to check the session cookie, and if unauthenticated redirects the browser through Pangolin's own OIDC flow. There is nothing to configure per-resource in Kubernetes — the resource's auth policy is set inside the Pangolin UI.
The Pocket-ID client is provisioned separately (see pocket-id docs). Do not reconfigure the OIDC client from the Pangolin side once it is working.
Ingress protection workflow
The end-to-end flow for a protected hostname like paperclip.basicstack.de:
- Operator creates a Resource in the Pangolin UI (hostname, backend Service, port, auth policy).
- Pangolin persists it in Postgres and exposes the new desired state at
http://pangolin.pangolin.svc.cluster.local:3001/api/v1/traefik-config. pangolin-kube-controllerpolls that endpoint on a ~15s loop.- On change, the controller server-side-applies into
ns=pangolin:IngressRoutefor the hostname (withtls.certResolver: letsencrypt).Middleware/badger(session check + OIDC redirect).TraefikService/1-<resource>-servicepointing at the backend Service in the app's namespace, port from the DB.
- Traefik picks the new CRDs up and starts serving the route.
- First request → redirected to Pocket-ID → session cookie → subsequent requests are proxied to the backend Service.
Adding a new protected ingress
Prereqs the target app must already satisfy:
- A Kubernetes
Servicein its own namespace, listening on a known port. - A DNS record for the hostname pointing at the cluster ingress IP.
- No competing plain
Ingressin another namespace for the same hostname. If one exists (e.g. a legacy direct ingress), delete it first — Traefik will otherwise route the pre-existing ingress and Pangolin's IngressRoute never wins.
Steps:
-
Log in to https://pangolin.basicstack.de (SSO via Pocket-ID, or local admin from the
pangolin-secretssealed secret). -
Create the Resource.
- Type: HTTP/HTTPS.
- Domain: the exact FQDN (e.g.
newapp.basicstack.de). - Base domain:
basicstack.de.
-
Target.
- Target type: HTTP.
- Backend URL:
http://<service>.<namespace>.svc.cluster.local:<port>— the port here is written to thetargets.portcolumn in Pangolin's Postgres and used by the controller when it generates theTraefikService. If the Service port later changes, update the target port in the UI (or via SQL:UPDATE targets SET port=<N> WHERE "targetId"=<id>;). - Path match:
/, strip prefix off.
-
TLS. Enable TLS, force HTTPS redirect. The Pangolin-generated IngressRoute hardcodes
tls.certResolver: letsencrypt, which is provisioned on our Traefik install (HTTP-01, persistent/dataPVC). Do not disable that resolver in the TraefikHelmChartConfigwithout also removing every Pangolin-generated route — Traefik will otherwise fail withnonexistent certificate resolver. -
Auth. Enable authentication, method SSO/OIDC. In Community Edition (what we run) there are no shared policies or group rules — add authorised users individually by email. If you need group-based rules, that's an Enterprise feature.
-
Save — controller applies CRDs within a poll interval (~15s).
-
Verify.
kubectl -n pangolin get ingressroute,middleware,traefikservice \ | grep -i <resource-name> kubectl -n pangolin logs -l app=pangolin-controller --tail=50 # HTTP smoke test curl -sSI https://<host>/ # expect 302 to Pocket-ID
Troubleshooting
- badger middleware returns HTTP 500. The middleware is trying to call
apiBaseUrland either the URL is not an FQDN or Pangolin isn't reachable fromkube-system. Confirmpangolin-config.yamlsetsserver.internal_hostnameandserver.badger_overridetopangolin.pangolin.svc.cluster.local— the short namepangolindoes not resolve from Traefik's namespace. - Controller logs
conflict with "controller" using traefik.io/v1alpha1: .spec…. A pre-existing CRD was created with theUpdateverb and the current controller uses server-sideApply. Delete the offendingMiddleware/TraefikService/IngressRouteonce — the controller recreates it via SSA and the conflict is gone. - Traefik logs
service port not found: <N>. The port stored in Pangolin'stargetstable doesn't match the Kubernetes Service port. Fix in the UI, or:UPDATE targets SET port=<N> WHERE "targetId"=<id>;(identifier case matters, double-quote it). - Traefik logs
nonexistent certificate resolver: letsencrypt. Theletsencryptresolver isn't loaded in Traefik. Every Pangolin IngressRoute references it — either define the resolver in Traefik (seeinfrastructure/networking/traefik-helmchartconfig.yaml) or overridetraefik.cert_resolverinpangolin-config.yaml. Grep the Traefik startup log forunable to get ACME account; that appears when the resolver was skipped entirely. - Route doesn't resolve cross-namespace. Traefik must run with
--providers.kubernetescrd.allowCrossNamespace=true. Pangolin's CRDs live inpangolin; backend Services usually don't. - CRDs never appear after saving a Resource.
Common causes: controller CrashLoop, wrongkubectl -n pangolin get pods -l app=pangolin-controller kubectl -n pangolin logs -l app=pangolin-controller --tail=200 kubectl -n pangolin get configmap pangolin-controller-config -o yamlCONFIG_ENDPOINT, missing auth header sealed secret, RBAC missing after a controller image upgrade. - Login loop / redirects to Pocket-ID forever. Clear cookies for both
pangolin.basicstack.deandauth.basicstack.de, confirm the OIDC callback URL matches the Pocket-ID client, and check Pangolin logs for OIDC errors:kubectl -n pangolin logs -l app=pangolin. - Pangolin pod stuck in
Init:0/2. Thewait-for-postgresinit container is looping onpg_isready. Checkpangolin-postgreshealth and thepangolin-postgres-secretsconnection details.
Critical notes — DO NOT DELETE
Pangolin holds live user/account/config state. Losing a PVC here means re-provisioning every protected resource, every target port, every user grant, and re-doing the OIDC client trust from scratch — plus temporary outages on every downstream site that Pangolin fronts.
pangolin-postgres-dataPVC (ns=pangolin, 10 Gi,hcloud-volumes-encrypted). Backs Postgres. Contains resources, targets, users, sessions, and everything the controller reads. Annotated withargocd.argoproj.io/sync-options: Delete=false— do not remove that annotation, and do not delete the PVC as a "reset" step. Snapshot the volume before any migration or destructive DB operation.pangolin-dataPVC (ns=pangolin, 10 Gi,hcloud-volumes-encrypted). Runtime files under/app/config. Also markedDelete=false. Do not delete.- Sealed secrets (
pangolin-secrets,pangolin-postgres-secrets,pangolin-controller-api-key) — rotating them out of order will lock the controller out of the internal API or take Pangolin off Postgres. Rotate one secret at a time and verify the corresponding pod comes back before proceeding. pangolin-configConfigMap. Editingserver.internal_hostname/server.badger_overrideback to the default short hostname will break the badger middleware cluster-wide (see Troubleshooting).- The Traefik
HelmChartConfiginkube-system(infrastructure/networking/traefik-helmchartconfig.yaml) — Pangolin depends onallowCrossNamespace=trueand on theletsencryptresolver being loaded with a persistent/dataPVC. Removing either silently breaks every Pangolin-managed route.
If access to Pangolin is broken, restore from a snapshot first; do not "reset" or re-provision the admin/OIDC layer. That is the standing rule for every stateful service in this cluster.
Related
- History and gotchas: internal memory
pangolin-kube-integration(from DEV-457). - Configuration walkthrough for the
paperclip.basicstack.deresource:pangolin-paperclip-configuration-guide-community.md. - Pangolin docs: https://docs.pangolin.net/
- pangolin-kube-controller: https://github.com/fosrl/pangolin-kube-controller