Deploy a Keycloak-backed go-go-goja Express auth host with GHCR, GitOps, Vault, Postgres, and Argo CD.
This tutorial explains the deployment path proven by goja-auth-host-demo on yolo.scapegoat.dev. It starts with an Express planned-auth host in go-go-goja, builds a container image, deploys it through K3s GitOps, reads secrets from Vault, authenticates through Keycloak, persists auth state in PostgreSQL, and validates the public browser flow.
The current live deployment image was built from examples/xgoja/19-express-keycloak-auth-host, a hand-composed Keycloak host that proved the production platform. Generated auth.mode=oidc is now available for xgoja serve; use examples/xgoja/21-generated-host-auth as the generated-binary starting point for new hosts while reusing the same Keycloak, Vault, Postgres, GitOps, and public smoke boundaries described here.
Public URL: https://goja-auth.yolo.scapegoat.dev
Image: ghcr.io/go-go-golems/go-goja-auth-host:sha-ba77afc
Argo app: goja-auth-host-demo
Namespace: goja-auth-host-demo
Keycloak issuer: https://auth.yolo.scapegoat.dev/realms/goja-auth-host-demo
Runtime secret: kv/apps/goja-auth-host-demo/prod/runtime
Postgres database: goja_auth_host_demo
The host serves planned Express routes. Public routes such as /healthz work without login. Protected routes such as /me require the app session created by the Keycloak callback. Unsafe routes require CSRF. App-owned resources and memberships are stored in PostgreSQL.
A working deployment requires these systems to agree:
source repo
-> GHCR image
-> GitOps deployment manifest
-> Vault runtime and pull secrets
-> Keycloak realm/client
-> PostgreSQL database and role
-> Argo CD Application
-> HTTPS Ingress
The app will not work if any one of these contracts drifts. The image can build successfully with the wrong Kubernetes args. The Pod can start with the wrong Keycloak redirect URI. Keycloak can authenticate the user while the app session cookie is rejected because the public URL and cookie settings are wrong.
A deployable auth host needs explicit configuration for the values that differ between local Docker Compose and the cluster.
Generated OIDC hosts expose these through the HTTP provider's Glazed-backed serve command:
--http-listen
--auth-mode
--auth-default-store-driver
--auth-default-store-dsn
--auth-default-store-apply-schema
--auth-oidc-issuer-url
--auth-oidc-client-id
--auth-oidc-client-secret
--auth-oidc-public-base-url
--auth-oidc-redirect-url
--auth-oidc-after-login-url
--auth-oidc-after-logout-url
--auth-session-cookie-allow-insecure-http
The original example 19 host exposes a hand-composed equivalent through its own Glazed serve command:
--listen LISTEN_ADDR
--script SCRIPT_PATH
--issuer KEYCLOAK_ISSUER
--client-id KEYCLOAK_CLIENT_ID
--client-secret KEYCLOAK_CLIENT_SECRET
--public-base-url PUBLIC_BASE_URL
--redirect-url KEYCLOAK_REDIRECT_URL
--after-login-url AFTER_LOGIN_URL
--after-logout-url AFTER_LOGOUT_URL
--allow-insecure-http ALLOW_INSECURE_HTTP
--session-db-dsn SESSION_DB_DSN
--audit-db-dsn AUDIT_DB_DSN
--app-db-dsn APPAUTH_DB_DSN
--capability-db-dsn CAPABILITY_DB_DSN
public-base-url is required in production. It is the browser-visible origin behind ingress. Do not derive callback URLs from --listen.
For local generated-host smokes:
make -C examples/xgoja/21-generated-host-auth smoke
make -C examples/xgoja/21-generated-host-auth compose-smoke
The first command validates generated build wiring with a fake discovery endpoint. The second command reuses the local Keycloak/Postgres Docker Compose stack, drives a real login, seeds demo appauth rows, and exercises the JavaScript-owned audit and capability routes.
For local Docker Compose with the original example 19 host:
go run ./examples/xgoja/19-express-keycloak-auth-host/cmd/host serve \
--listen 127.0.0.1:8790 \
--public-base-url http://127.0.0.1:8790 \
--allow-insecure-http
For Kubernetes:
PUBLIC_BASE_URL=https://goja-auth.yolo.scapegoat.dev
ALLOW_INSECURE_HTTP=false
The derived redirect URL is:
https://goja-auth.yolo.scapegoat.dev/auth/callback
Dockerfile.auth-host builds the example host and copies the route script into the runtime image:
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/goja-auth-host ./examples/xgoja/19-express-keycloak-auth-host/cmd/host
COPY --from=builder /out/goja-auth-host /app/goja-auth-host
COPY examples/xgoja/19-express-keycloak-auth-host/scripts/server.js /app/server.js
ENTRYPOINT ["/app/goja-auth-host", "serve"]
CMD ["--listen", ":8080", "--script", "/app/server.js"]
Build and push:
docker build -f Dockerfile.auth-host \
-t ghcr.io/go-go-golems/go-goja-auth-host:sha-<short-sha> .
docker push ghcr.io/go-go-golems/go-goja-auth-host:sha-<short-sha>
The workflow .github/workflows/publish-auth-host-image.yaml performs the same build in CI and can open a GitOps PR through deploy/gitops-targets.json.
The K3s package is a normal Kustomize directory:
gitops/kustomize/goja-auth-host-demo/
namespace.yaml
serviceaccount.yaml
db-bootstrap-serviceaccount.yaml
vault-connection.yaml
vault-auth.yaml
db-bootstrap-vault-auth.yaml
runtime-secret.yaml
image-pull-secret.yaml
postgres-admin-secret.yaml
db-bootstrap-script-configmap.yaml
db-bootstrap-job.yaml
deployment.yaml
service.yaml
ingress.yaml
The Deployment uses the pushed image and reads runtime settings from the VSO-rendered Secret:
image: ghcr.io/go-go-golems/go-goja-auth-host:sha-ba77afc
args:
- --listen
- :8080
- --script
- /app/server.js
env:
- name: KEYCLOAK_ISSUER
valueFrom:
secretKeyRef:
name: goja-auth-host-demo-runtime
key: keycloak_issuer
The args field intentionally does not include serve. The image ENTRYPOINT already includes the subcommand.
Create the runtime secret:
export VAULT_ADDR=https://vault.yolo.scapegoat.dev
export VAULT_TOKEN="$(cat ~/.vault-token)"
GOJA_AUTH_HOST_DEMO_KEYCLOAK_ISSUER=https://auth.yolo.scapegoat.dev/realms/goja-auth-host-demo \
GOJA_AUTH_HOST_DEMO_KEYCLOAK_CLIENT_ID=goja-auth-host-demo \
GOJA_AUTH_HOST_DEMO_KEYCLOAK_CLIENT_SECRET=<client-secret> \
./scripts/bootstrap-goja-auth-host-demo-runtime-secrets.sh
Create the GHCR pull secret:
GITHUB_DEPLOY_PAT="$(gh auth token)" GITHUB_DEPLOY_USERNAME=wesen \
./scripts/bootstrap-goja-auth-host-demo-image-pull-secret.sh
The runtime secret contains the database DSN and Keycloak fields. The image-pull secret contains Docker auth fields and is transformed by VSO into a kubernetes.io/dockerconfigjson Secret.
The live demo currently uses manual kcadm.sh provisioning:
Realm: goja-auth-host-demo
Client: goja-auth-host-demo
Access type: confidential
Redirect URI: https://goja-auth.yolo.scapegoat.dev/auth/callback
Web origin: https://goja-auth.yolo.scapegoat.dev
For a long-lived deployment, move this state into the Terraform Keycloak repository. The client secret must be copied into Vault after creation.
The GitOps package includes an Argo sync-hook Job that creates or updates the database role and database. The app runtime Secret supplies:
database = goja_auth_host_demo
username = goja_auth_host_demo_app
password = generated
The bootstrap Job reads the shared Postgres admin secret from Vault and runs psql. After that, the app starts and each auth store applies its own schema.
Apply or merge the Argo Application:
kubectl apply -f gitops/applications/goja-auth-host-demo.yaml
kubectl -n argocd annotate application goja-auth-host-demo argocd.argoproj.io/refresh=hard --overwrite
For pre-merge validation, the live Application may temporarily target a feature branch. After merge, switch it back to main.
Check status:
kubectl -n argocd get application goja-auth-host-demo
kubectl -n goja-auth-host-demo get pods,svc,ingress,certificate,vaultauth,vaultstaticsecret
The expected state is Synced Healthy, one running app Pod, a ready TLS certificate, and VSO resources with SYNCED=True, HEALTHY=True, and READY=True.
Start with health:
curl -fsS https://goja-auth.yolo.scapegoat.dev/healthz
Check the login redirect with GET:
curl -sS -D - -o /dev/null https://goja-auth.yolo.scapegoat.dev/auth/login
Run the full smoke test:
python3 examples/xgoja/19-express-keycloak-auth-host/scripts/keycloak_smoke.py \
--base-url https://goja-auth.yolo.scapegoat.dev \
--username demo-user \
--password "$(VAULT_TOKEN=$(cat ~/.vault-token) vault kv get -field=demo_password kv/apps/goja-auth-host-demo/prod/runtime)"
A passing smoke proves more than liveness. It proves public routes, unauthenticated denial, Keycloak login, session creation, CSRF enforcement, authorization, capability token behavior, logout, and post-logout denial.
| Problem | Cause | Solution |
|---|---|---|
Pod exits with Too many arguments | The Deployment passed serve even though the image ENTRYPOINT already includes it. | Remove serve from Kubernetes args. |
Vault bootstrap says VAULT_TOKEN required | Operator shell has no token. | Export VAULT_TOKEN=$(cat ~/.vault-token) or log in with OIDC. |
Image pull secret bootstrap says GITHUB_DEPLOY_PAT is missing | No GHCR credential was provided. | Export a token with package read access. |
| Keycloak callback fails | Redirect URI or client secret mismatch. | Compare Keycloak client config, Vault secret, and PUBLIC_BASE_URL. |
curl -I /auth/login returns 405 | HEAD is not the login method. | Use GET for redirect checks. |
| Smoke hangs after success | Server may not handle SIGTERM. | Add signal-aware http.Server.Shutdown. |
The deployed service is functional, but the original image is still the hand-composed example-19 architecture. To migrate it to the generated architecture, build a generated binary from an xgoja.yaml like examples/xgoja/21-generated-host-auth/xgoja.yaml, keep the same HTTPS public-base-url, Keycloak client, Vault secret fields, and Postgres DSN, then rerun the public smoke test. Keep replicas at 1 until OIDC transaction storage is durable across pods.
goja-repl help auth-module-guidegoja-repl help express-auth-user-guidegoja-repl help express-auth-examplesxgoja help generated-auth-javascript-apisxgoja help express-auth-host-integration-guidexgoja help hostauth-config-referencexgoja help auth-host-production-runbook