Helm chart reference
This is the reference for the shoehorn Helm chart. For the guided five-step path, start at Get Started. Source: shoehorn-dev/helm-charts.
Prerequisites
Section titled “Prerequisites”- Kubernetes 1.24+
- Helm 4.0+
kubectlconfigured for your cluster- A storage class for persistent volumes
- DNS pointing to your cluster’s ingress
- An ingress controller (Traefik or Envoy Gateway recommended)
- cert-manager installed out-of-band if you want automatic TLS
Architecture
Section titled “Architecture”A Shoehorn deployment consists of:
- API - REST API gateway
- Web - Svelte frontend
- Worker - Background job processor
- Crawler - GitHub repository discovery
- Forge - Workflow engine
- EventBus - Event streaming manager
- PostgreSQL (or external managed)
- Meilisearch
- Valkey (or external managed)
- Redpanda (or external managed)
- Cerbos - Authorization engine
Every service defaults to a single replica. Scale up the stateless services (API, Web, Worker, Crawler, Forge) when you want HA. Two replicas plus a surge during rolling upgrade keeps the platform available across pod restarts and node failures; pick numbers that fit your SLO and cluster capacity.
Step 1: Provide credentials as Kubernetes Secrets
Section titled “Step 1: Provide credentials as Kubernetes Secrets”The chart never creates Secrets. Each credential is referenced by a typed *SecretRef block that maps to Kubernetes’ native valueFrom.secretKeyRef:
<thing>SecretRef: name: <kubernetes-secret-name> # optional if secret.defaultName is set key: <key-inside-secret>You have two workflows:
Workflow A: one Secret for everything (simplest)
Section titled “Workflow A: one Secret for everything (simplest)”Create the namespace and a single Secret with every credential as a key. Then set secret.defaultName in values so every *SecretRef resolves to that Secret by default.
kubectl create namespace shoehorn
kubectl create secret generic shoehorn-credentials -n shoehorn \ --from-literal=postgres_password="$(openssl rand -base64 24)" \ --from-literal=db_password="$(openssl rand -base64 24)" \ --from-literal=valkey_password="$(openssl rand -base64 24)" \ --from-literal=meilisearch_master_key="$(openssl rand -hex 32)" \ --from-literal=jwt_secret="$(openssl rand -hex 32)" \ --from-literal=auth_encryption_key="$(openssl rand -base64 32)" \ --from-literal=secrets_encryption_key="$(openssl rand -hex 32)"For auth providers that need a client secret or PAT, add the matching key to the same Secret (or to a separate Secret you reference by name:). See Step 2: Authentication.
Workflow B: per-credential Secrets (ESO, Sealed Secrets, Vault)
Section titled “Workflow B: per-credential Secrets (ESO, Sealed Secrets, Vault)”Set name: explicitly on each *SecretRef. Each credential domain can sync from its own upstream path. The chart ships examples/values-eso-vault.yaml covering this pattern.
Credential reference
Section titled “Credential reference”| Env var | Values path | Notes |
|---|---|---|
POSTGRES_PASSWORD | postgresql.superuserPasswordSecretRef | shoehorn_user, BYPASSRLS, runs migrations |
DB_PASSWORD | postgresql.passwordSecretRef | app_user, NOBYPASSRLS, runtime queries |
VALKEY_PASSWORD | valkey.passwordSecretRef | |
MEILI_MASTER_KEY | meilisearch.masterKeySecretRef | Same value used by server and clients |
JWT_SECRET | auth.session.jwtSecretRef | Required |
AUTH_ENCRYPTION_KEY | auth.session.encryptionKeyRef | Required |
SECRETS_ENCRYPTION_KEY | auth.session.secretsEncryptionKeyRef | Required |
OKTA_CLIENT_SECRET | auth.okta.clientSecretRef | Required when auth.provider=okta |
OKTA_API_TOKEN | auth.okta.apiTokenSecretRef | Optional, for Okta orgdata sync |
ZITADEL_SERVICE_USER_PAT | auth.zitadel.serviceUserPatSecretRef | Optional, for Zitadel orgdata sync |
ARGOCD_TOKEN | auth.argocd.tokenSecretRef | Optional |
UPCLOUD_TOKEN | cloudProviders.upcloud.tokenSecretRef | Required when UpCloud sync enabled |
SMTP_PASSWORD | smtp.passwordSecretRef | Required when smtp.enabled |
Public identifiers (auth.github.appId, auth.github.installationId, auth.zitadel.projectId, auth.zitadel.clientId) are plain values, not Secret references.
File-based credentials (GitHub App private key)
Section titled “File-based credentials (GitHub App private key)”GitHub App private keys must land on disk as files. Mount them via extraVolumes / extraVolumeMounts:
extraVolumes:- name: github-private-key secret: secretName: shoehorn-credentials items: - key: github_app_private_key path: private-key
extraVolumeMounts:- name: github-private-key mountPath: /var/secrets/github readOnly: trueAdd github_app_private_key to your Secret with the contents of the .pem file.
Step 2: Create your values file
Section titled “Step 2: Create your values file”global: domain: idp.example.com # required; the hostname customers reach Shoehorn on storageClass: "standard" # your cluster's storage class organization: slug: "my-org" # URL-safe org identifier, required
image: tag: "v0.5.22" # always pin; never use "latest"
# Defaults are 1 each. Raise for HA.replicaCount: api: 1 web: 1 worker: 1 crawler: 1 forge: 1 eventbus: 1
# Falls through to every *SecretRef that omits `name:`secret: defaultName: shoehorn-credentials
# Built-in datastores (set external.enabled: true to bring your own)postgresql: superuserPasswordSecretRef: key: postgres_password passwordSecretRef: key: db_password persistence: size: 20Gi
valkey: passwordSecretRef: key: valkey_password
meilisearch: masterKeySecretRef: key: meilisearch_master_key
# Session keys (all required)auth: provider: okta # zitadel, okta, or entra-id
session: jwtSecretRef: key: jwt_secret encryptionKeyRef: key: auth_encryption_key secretsEncryptionKeyRef: key: secrets_encryption_key
okta: domain: acme.okta.com clientId: "0oa..." issuer: https://acme.okta.com/oauth2/default clientSecretRef: key: okta_client_secret
# Bootstrap admin. The matching account picks up tenant:admin on first# sign-in, but only while the roles table is empty. Set exactly one of# user or group; set neither once roles are managed in the UI.rbac: roleAssignment: tenantAdmin: user: "platform@acme.com" # group: "shoehorn-admins"
# Ingress (pick one)ingressRoute: enabled: true # Traefik IngressRoute (default) tls: enabled: true certResolver: letsencryptStep 3: Install the chart
Section titled “Step 3: Install the chart”helm install shoehorn oci://ghcr.io/shoehorn-dev/helm-charts/shoehorn \ --namespace shoehorn \ --values values.yaml \ --waitThe chart fails template rendering when a required *SecretRef can’t resolve to a Secret name, or when an auth provider is missing required fields. Errors surface as plain messages in helm install output.
Step 4: Verify
Section titled “Step 4: Verify”kubectl get pods -n shoehornkubectl get ingressroute -n shoehorn # or: kubectl get ingress -n shoehorn
kubectl port-forward -n shoehorn svc/shoehorn-api 8080:8080curl http://localhost:8080/healthzGet the load-balancer IP and point DNS at it:
kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}'| Record | Type | Value |
|---|---|---|
shoehorn.example.com | A | <load-balancer-ip> |
auth.example.com | A | <load-balancer-ip> (if you host the IdP behind the same ingress) |
Multi-tenant RLS
Section titled “Multi-tenant RLS”PostgreSQL Row-Level Security is always on. There is no toggle.
| User | RLS | Purpose |
|---|---|---|
shoehorn_user | BYPASSRLS | Schema migrations and admin operations |
app_user | NOBYPASSRLS | All runtime queries; RLS policies enforced by PostgreSQL |
The chart runs a migration init container on the API deployment using shoehorn_user, then every runtime service connects as app_user. For single-tenant deployments, RLS still runs but the middleware injects a fixed tenant ID derived from global.organization.slug.
Trusted proxies
Section titled “Trusted proxies”The API runs behind your ingress controller, so every request reaches it with the proxy’s IP as the source. To recover the real client IP it reads the X-Forwarded-For and X-Forwarded-Proto headers, but only from sources you list in global.trustedProxies. Without that allowlist a client could spoof those headers.
Leave global.trustedProxies empty and the API uses the proxy’s IP as the client IP. Rate limiting then buckets every caller together, and request logs and audit records show the proxy instead of the caller. Login and authorization are unaffected; this is only about IP attribution.
Set it to the range your ingress traffic comes from. For Traefik or another in-cluster ingress controller, that’s the pod network CIDR:
global: trustedProxies: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"Malformed entries and catch-all ranges (0.0.0.0/0, ::/0) are dropped at startup with a warning in the API log. A catch-all would trust every source and defeat the check.
Persistence
Section titled “Persistence”| Component | Default size | Notes |
|---|---|---|
| PostgreSQL | 20Gi | Survives helm uninstall (helm.sh/resource-policy: keep) |
| Meilisearch | 10Gi | Search indexes |
| Valkey | - | In-memory cache, no persistence |
| Redpanda | - | Event streaming, optional persistence |
Resource sizing
Section titled “Resource sizing”The chart defaults are tuned for production. Two informal sizing points:
# Small (up to 50 entities)api: resources: requests: { cpu: 100m, memory: 256Mi } limits: { cpu: 500m, memory: 512Mi }
# Medium (50–500 entities)api: resources: requests: { cpu: 250m, memory: 512Mi } limits: { cpu: 1000m, memory: 1Gi }Enable autoscaling per-service for larger installs:
api: autoscaling: enabled: true minReplicas: 3 maxReplicas: 10 targetCPUUtilizationPercentage: 70Upgrading
Section titled “Upgrading”helm upgrade shoehorn oci://ghcr.io/shoehorn-dev/helm-charts/shoehorn \ --namespace shoehorn --values values.yaml --wait
helm history shoehorn -n shoehornhelm rollback shoehorn 1 -n shoehornThe postgres StatefulSet uses updateStrategy: OnDelete. Chart upgrades don’t restart the database pod. Roll it explicitly when bumping the postgres image:
kubectl delete pod -n shoehorn shoehorn-postgresql-0The postgres image tag is pinned in values.yaml and tracks postgres releases, not platform releases.
Uninstalling
Section titled “Uninstalling”helm uninstall shoehorn -n shoehornThe PostgreSQL StatefulSet survives (helm.sh/resource-policy: keep). A reinstall reattaches the same data. To drop the database explicitly:
kubectl delete sts -n shoehorn shoehorn-postgresqlkubectl delete pvc -n shoehorn --allkubectl delete namespace shoehornTroubleshooting
Section titled “Troubleshooting”# Pod state and logskubectl get pods -n shoehornkubectl logs -n shoehorn <pod-name>kubectl describe pod -n shoehorn <pod-name>
# Confirm the keys in your Secretkubectl get secret shoehorn-credentials -n shoehorn -o jsonpath='{.data}' | jq 'keys'
# Databasekubectl logs -n shoehorn -l app.kubernetes.io/component=postgresql
# Ingresskubectl get ingressroute -n shoehorn # Traefikkubectl get ingress -n shoehorn # standard