Skip to content

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.

  • Kubernetes 1.24+
  • Helm 4.0+
  • kubectl configured 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

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.

Terminal window
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.

Env varValues pathNotes
POSTGRES_PASSWORDpostgresql.superuserPasswordSecretRefshoehorn_user, BYPASSRLS, runs migrations
DB_PASSWORDpostgresql.passwordSecretRefapp_user, NOBYPASSRLS, runtime queries
VALKEY_PASSWORDvalkey.passwordSecretRef
MEILI_MASTER_KEYmeilisearch.masterKeySecretRefSame value used by server and clients
JWT_SECRETauth.session.jwtSecretRefRequired
AUTH_ENCRYPTION_KEYauth.session.encryptionKeyRefRequired
SECRETS_ENCRYPTION_KEYauth.session.secretsEncryptionKeyRefRequired
OKTA_CLIENT_SECRETauth.okta.clientSecretRefRequired when auth.provider=okta
OKTA_API_TOKENauth.okta.apiTokenSecretRefOptional, for Okta orgdata sync
ZITADEL_SERVICE_USER_PATauth.zitadel.serviceUserPatSecretRefOptional, for Zitadel orgdata sync
ARGOCD_TOKENauth.argocd.tokenSecretRefOptional
UPCLOUD_TOKENcloudProviders.upcloud.tokenSecretRefRequired when UpCloud sync enabled
SMTP_PASSWORDsmtp.passwordSecretRefRequired 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: true

Add github_app_private_key to your Secret with the contents of the .pem 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: letsencrypt
Terminal window
helm install shoehorn oci://ghcr.io/shoehorn-dev/helm-charts/shoehorn \
--namespace shoehorn \
--values values.yaml \
--wait

The 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.

Terminal window
kubectl get pods -n shoehorn
kubectl get ingressroute -n shoehorn # or: kubectl get ingress -n shoehorn
kubectl port-forward -n shoehorn svc/shoehorn-api 8080:8080
curl http://localhost:8080/healthz

Get the load-balancer IP and point DNS at it:

Terminal window
kubectl get svc -n traefik traefik -o jsonpath='{.status.loadBalancer.ingress[0].ip}'
RecordTypeValue
shoehorn.example.comA<load-balancer-ip>
auth.example.comA<load-balancer-ip> (if you host the IdP behind the same ingress)

PostgreSQL Row-Level Security is always on. There is no toggle.

UserRLSPurpose
shoehorn_userBYPASSRLSSchema migrations and admin operations
app_userNOBYPASSRLSAll 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.

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.

ComponentDefault sizeNotes
PostgreSQL20GiSurvives helm uninstall (helm.sh/resource-policy: keep)
Meilisearch10GiSearch indexes
Valkey-In-memory cache, no persistence
Redpanda-Event streaming, optional persistence

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: 70
Terminal window
helm upgrade shoehorn oci://ghcr.io/shoehorn-dev/helm-charts/shoehorn \
--namespace shoehorn --values values.yaml --wait
helm history shoehorn -n shoehorn
helm rollback shoehorn 1 -n shoehorn

The postgres StatefulSet uses updateStrategy: OnDelete. Chart upgrades don’t restart the database pod. Roll it explicitly when bumping the postgres image:

Terminal window
kubectl delete pod -n shoehorn shoehorn-postgresql-0

The postgres image tag is pinned in values.yaml and tracks postgres releases, not platform releases.

Terminal window
helm uninstall shoehorn -n shoehorn

The PostgreSQL StatefulSet survives (helm.sh/resource-policy: keep). A reinstall reattaches the same data. To drop the database explicitly:

Terminal window
kubectl delete sts -n shoehorn shoehorn-postgresql
kubectl delete pvc -n shoehorn --all
kubectl delete namespace shoehorn
Terminal window
# Pod state and logs
kubectl get pods -n shoehorn
kubectl logs -n shoehorn <pod-name>
kubectl describe pod -n shoehorn <pod-name>
# Confirm the keys in your Secret
kubectl get secret shoehorn-credentials -n shoehorn -o jsonpath='{.data}' | jq 'keys'
# Database
kubectl logs -n shoehorn -l app.kubernetes.io/component=postgresql
# Ingress
kubectl get ingressroute -n shoehorn # Traefik
kubectl get ingress -n shoehorn # standard