# Install on Kubernetes

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

## 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](https://docs.gorunner.io/self-host/own-stores).
- **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](https://docs.gorunner.io/self-host/install-docker#sign-in-to-the-registry). Your license comes from GoRunner too: ask at gorunner.io/contact, [free](https://docs.gorunner.io/self-host/free-license) 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](https://docs.gorunner.io/self-host/helm-values#secrets).

```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`](https://docs.gorunner.io/self-host/helm-values#admin.allowOpenFirstSignup).
- `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](https://docs.gorunner.io/self-host/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](https://docs.gorunner.io/self-host/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
```

```text
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](https://docs.gorunner.io/self-host/first-sign-in) takes it from there.

```bash
kubectl --namespace gorunner \
  exec deploy/gorunner-api -- gorunner setup-code
```

```text
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-nginx**

```
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
```

**Traefik**

```
ingress:
  className: traefik
  nginxDefaults: false
networkPolicy:
  apiFrom:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: traefik
```

**AWS Load Balancer Controller**

```
ingress:
  className: alb
  nginxDefaults: false
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]'
    alb.ingress.kubernetes.io/certificate-arn: <your certificate's ARN>
    alb.ingress.kubernetes.io/healthcheck-path: /api/ready
    alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=3600
api:
  trustedProxies: "10.0.1.0/24 10.0.2.0/24"
networkPolicy:
  apiFrom:
    - ipBlock:
        cidr: 10.0.1.0/24
    - ipBlock:
        cidr: 10.0.2.0/24
```

- **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](https://docs.gorunner.io/self-host/own-proxy#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

- [First sign-in](https://docs.gorunner.io/self-host/first-sign-in): The admin account, your license and two-factor · 5 min
- [Helm values](https://docs.gorunner.io/self-host/helm-values): Every value, with its default
- [Update](https://docs.gorunner.io/self-host/update): helm upgrade, and going back · 5 min

Source: https://docs.gorunner.io/self-host/install-kubernetes
