GoRunner docs
Search or askSearch⌘K

Install on Kubernetes

Install GoRunner in your cluster with the Helm chart, on your own database, Redis and S3.

30 min · Self-hosted · Needs a Kubernetes cluster

You need

A cluster and your own stores
Kubernetes 1.29 or newer, with kubectl and Helm 3.8 or newer on your computer. An ingress controller with certificates (such as cert-manager), and a network plugin that enforces NetworkPolicy. PostgreSQL 15 or newer and Redis 6.2 or newer, set never to delete keys; an S3 bucket is optional. What each must be.
A domain name
Such as automations.example.com. You point it at your ingress controller in step 4.
Access to GoRunner’s images
A GitHub account GoRunner gave access to, and a read-only token for it, as in Install with Docker. Your license comes from GoRunner too: ask at gorunner.io/contact, free or paid.

1.Make a namespace

The chart and the image are private for now. Sign Helm in with that GitHub account’s user name, and paste the token when it asks. Then make the namespace and give it the same user name and token, so every pod can download the image.

A registry mirror inside your company? Use its address in place of ghcr.io, and set image.repository to its copy of gorunner-selfhost in step 3.

bash
helm registry login ghcr.io -u <user name>
kubectl create namespace gorunner
kubectl --namespace gorunner \
  create secret docker-registry ghcr \
  --docker-server=ghcr.io \
  --docker-username=<user name> \
  --docker-password=<token>

2.Make the secrets

Passwords and keys go in one Secret, its keys named as the settings. The file keeps them out of your shell history.

DATABASE_URL, REDIS_URL
Your database and Redis. Use rediss:// when Redis needs TLS.
S3_…
Your bucket’s key. Leave both out with no bucket.
SMTP_PASSWORD
Or RESEND_API_KEY. Leave both out with no mail.

Danger ENCRYPTION_KEY opens every password and connection GoRunner stores. Copy the file into your password manager now, then delete it. Lose the key, or change it, and nothing stored can be read again.

Use Sealed Secrets, Vault or separate fields?

Any Secret works the same: give its name as existingSecret.

For separate fields, put only POSTGRES_PASSWORD and REDIS_PASSWORD in the Secret, and the hosts and the database user in your values (postgresql.host, postgresql.user, redis.host). Then no character in a password needs escaping.

The chart can also write the Secret itself from values, but then the secrets live in your values file. See Helm values.

bash
umask 077
cat > gorunner-secrets.env <<'EOF'
DATABASE_URL=postgresql://gorunner:<password>@db.example.internal:5432/gorunner?sslmode=require
REDIS_URL=rediss://:<password>@redis.example.internal:6379/0
S3_ACCESS_KEY=<access key>
S3_SECRET_KEY=<secret key>
SMTP_PASSWORD=<password>
EOF
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" \
  >> gorunner-secrets.env
kubectl --namespace gorunner \
  create secret generic gorunner-secrets \
  --from-env-file=gorunner-secrets.env

3.Write your values

Everything else goes in a values file. This one suits ingress-nginx with cert-manager.

admin.email
The only address that can make the first account. With existingSecret, set it here or as ADMIN_EMAIL in the Secret: without either, nobody can, unless you set admin.allowOpenFirstSignup: true.
ingress.host
The address people open. With tls.enabled, it is https.
mail
smtp or resend. Leave it out for no mail: people then sign in with a password.
objectStorage
Your bucket. mode: none keeps step data in the database.

Every value is in Helm values.

values.yaml
existingSecret: gorunner-secrets
imagePullSecrets:
  - name: ghcr

admin:
  email: admin@example.com

ingress:
  className: nginx
  host: automations.example.com
  tls:
    enabled: true
    secretName: gorunner-tls
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt

mail:
  backend: smtp
  from: "GoRunner <no-reply@example.com>"
  smtp:
    host: smtp.example.com
    port: 587
    user: apikey

objectStorage:
  mode: external
  bucket: gorunner
  region: eu-west-1
  external:
    endpoint: https://s3.eu-west-1.amazonaws.com

4.Install

Name the version without its leading v. Helm runs the migrations first, with the preflight check. The app and the workers start when they finish.

You should see after a minute or two, every pod is Running and 1/1, and the migrations’ pod is Completed.

Then helm test checks the dashboard, readiness and the embed’s rules. It ends Phase: Succeeded.

Once kubectl --namespace gorunner get ingress shows an address, point your domain name at it.

bash
helm install gorunner \
  oci://ghcr.io/ralve-org/charts/gorunner \
  --version 2026.10.30 --namespace gorunner \
  -f values.yaml
Output, the start
GoRunner v2026.10.30 is installing as "gorunner" in namespace "gorunner".
bash
kubectl --namespace gorunner get pods --watch
helm test gorunner --namespace gorunner
kubectl --namespace gorunner get ingress

5.Create the admin account

Read the setup code from the app. Only someone who can reach the cluster has it.

Open your address, enter the code, and create the admin account. First sign-in takes it from there.

bash
kubectl --namespace gorunner \
  exec deploy/gorunner-api -- gorunner setup-code
Output
K7QM-4RZP

The ingress

Your ingress controller ends https and passes every request to the app’s pods. Whichever you run, it must:

Stream answers
No response buffering. The live run view and the embed are streams.
Wait long
A read timeout of at least 60 seconds; an hour is safe. GoRunner sends a keep-alive every 25 seconds.
Take 6 MiB
Request bodies up to 6 MiB. GoRunner takes up to 5 MiB.
Add no security headers
No X-Frame-Options or Content-Security-Policy of its own. GoRunner sets its own on every page; the embed needs them.
See the caller
Put the controller’s Service on externalTrafficPolicy: Local, or use the PROXY protocol. Otherwise every caller shares one set of limits.

The chart’s network policy lets only your controller reach the app. It admits the ingress-nginx namespace by default; for any other controller, change networkPolicy.apiFrom, or the app answers nobody.

ingress:
  className: nginx
  # nginxDefaults: true (the default) adds these annotations:
  #   nginx.ingress.kubernetes.io/proxy-buffering: "off"
  #   nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
  #   nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
  #   nginx.ingress.kubernetes.io/proxy-body-size: 6m
ingress-nginx
Nothing to add: with className: nginx, the chart sets the four annotations above. Its maintainers retired it in March 2026; it still works.
Traefik
Use the namespace Traefik runs in (kube-system on k3s). Add no buffering middleware, and keep the entry point’s writeTimeout at 0 or over an hour.
AWS Load Balancer Controller
With target-type: ip, the load balancer reaches the pods from its own subnets, so the trusted ranges and the network policy name those subnets. Use your own.
Who does GoRunner believe about the caller’s address?

Left empty, api.trustedProxies trusts every private address. That is safe only because the network policy lets nothing but your controller reach the app, so your network plugin must enforce it. helm test checks that only when the policy closes something to it: a bundled store, or telemetry.metrics.enabled. Otherwise it prints skip NetworkPolicy enforcement.

Turn the policy off, and api.trustedProxies must name your controller’s own addresses. See The address ranges to trust.

Can I try it without stores of my own?

Yes, on a test cluster. The chart runs its own PostgreSQL, Redis and SeaweedFS, and makes their passwords. Leave DATABASE_URL, REDIS_URL and the S3 keys out of the Secret.

values.yaml
postgresql:
  bundled:
    enabled: true
redis:
  bundled:
    enabled: true
objectStorage:
  mode: bundled

Each is one pod with one volume: no backups, no failover. Its pods carry gorunner.io/evaluation-only: "true". The only way onto your own stores later is a dump and restore.

Next

Checked against 12d85b3b