Kubernetes (Helm)
The Helm chart runs the Nautelle control plane on a Kubernetes cluster: the
published ghcr.io/andreybegma/nautelle-api and nautelle-web images, a Caddy
proxy that gives both one address, and optionally its own PostgreSQL and Redis
(ADR-0092).
It is the same install as the compose file of Install,
in Kubernetes objects.
The chart is the control plane only. Runners stay on the machines that hold your repositories, a developer’s laptop or a build host, and pair against the chart’s address (Pairing a runner).
You need:
- Kubernetes 1.25 or newer, with amd64 or arm64 nodes;
- Helm 3.8 or newer, for OCI charts;
- a default StorageClass that provides
ReadWriteOncevolumes, unless you bring your own claims; - to reach it from outside the cluster: an Ingress controller, a Cloudflare
tunnel or a
LoadBalancerService.
Quick start
Section titled “Quick start”helm install nautelle oci://ghcr.io/andreybegma/charts/nautelle \ --version X.Y.Z --namespace nautelle --create-namespacekubectl -n nautelle rollout status deploy/nautelle-apikubectl -n nautelle port-forward svc/nautelle 8000:80Then open http://localhost:8000. The chart’s version is the product’s: chart
X.Y.Z runs the images of release X.Y.Z. The releases are listed in
docs/releases.
On its first start the API waits for the database, migrates it and generates
its encryption and audit keys into the /data volume; the startup probe gives
it five minutes. The bundled PostgreSQL and Redis get random passwords, which
the chart keeps in the Secret nautelle-bundled across upgrades.
From a checkout of the repository, helm install nautelle deploy/helm/nautelle
installs the chart as it is on that branch, with the :latest images.
The images come from ghcr.io/andreybegma. The same images, with the same
digests and signatures, are on Docker Hub: --set image.registry=docker.io/abegma
pulls docker.io/abegma/nautelle-api and nautelle-web instead. An
api.image.repository or web.image.repository that names a full path, such
as a mirror of your own, ignores image.registry.
First run
Section titled “First run”Open the one-time setup link the API logs and create the first administrator there (ADR-0087). Each start of the API logs a new link; to find the current one:
kubectl -n nautelle logs deploy/nautelle-api | grep -o 'http[^ ]*/setup?token=[^ ]*' | tail -n 1The link starts with nautelleUrl. Behind a port-forward it is
http://localhost:8000/setup?token=…, which is the default.
For an install nobody opens a browser on, put ADMIN_EMAIL and
ADMIN_PASSWORD in the API’s Secret (Secrets); the API creates
that account on its first start. Remove them from the Secret afterwards.
Reaching it
Section titled “Reaching it”nautelleUrl is the address people open. The API’s API_URL, WEB_URL,
APP_URL and PUBLIC_URL derive from it, as NAUTELLE_URL does in the
compose install. An https:// address runs the API as APP_ENV=production
(Secure cookies, the GitHub App webhook on); an http:// one as
development, because a browser drops Secure cookies on plain HTTP. appEnv
overrides that.
The Service nautelle (port 80) is the proxy: /api/* goes to the API with
the prefix stripped, WebSockets included, and everything else to the web app.
Requests are limited to 10 MB. Whatever you put in front of it points at that
Service and nothing else.
Ingress
Section titled “Ingress”nautelleUrl: https://dock.example.comingress: enabled: true className: nginx annotations: nginx.ingress.kubernetes.io/proxy-body-size: 10m nginx.ingress.kubernetes.io/proxy-read-timeout: '3600' nginx.ingress.kubernetes.io/proxy-send-timeout: '3600' tls: enabled: true certManager: clusterIssuer: letsencrypthelm install nautelle oci://ghcr.io/andreybegma/charts/nautelle --version X.Y.Z \ -n nautelle --create-namespace -f values.yamlThe host is taken from nautelleUrl unless ingress.host names another.
With an issuer set, the chart adds cert-manager’s
cert-manager.io/cluster-issuer (or cert-manager.io/issuer) annotation, and
cert-manager writes the certificate into ingress.tls.secretName (default
nautelle-tls). Without one, that Secret must already exist. TLS ends at the
Ingress controller. The long timeouts keep the runners’ WebSockets open; any
controller works, these annotations are ingress-nginx’s.
Cloudflare tunnel
Section titled “Cloudflare tunnel”No Ingress and no open port: cloudflared runs beside Caddy in the proxy pod
and connects out to Cloudflare.
-
In the Cloudflare dashboard, create a tunnel (Zero Trust → Networks → Tunnels) and copy its token.
-
Add a public hostname to the tunnel, for example
dock.example.com, with the servicehttp://localhost:8080. -
Install with the token and the tunnel’s address:
Terminal window helm install nautelle oci://ghcr.io/andreybegma/charts/nautelle --version X.Y.Z \-n nautelle --create-namespace \--set nautelleUrl=https://dock.example.com \--set tunnel.token=<token>
To keep the token out of Helm’s release history, put it in a Secret of your
own and set tunnel.existingSecret (key TUNNEL_TOKEN, or
tunnel.existingSecretKey) instead of tunnel.token.
LoadBalancer
Section titled “LoadBalancer”--set service.type=LoadBalancer exposes the proxy directly, over plain
HTTP. Set nautelleUrl to the address it gets. Use an Ingress with TLS or a
tunnel for anything beyond a trusted network.
Secrets
Section titled “Secrets”existingSecret names a Secret whose every key becomes an environment
variable of the API. It is the place for anything in
apps/api/.env.example that is a secret:
DEEPL_API_KEY, SPEECH_TO_TEXT_API_KEY, ADMIN_EMAIL and
ADMIN_PASSWORD. The Telegram bot, the GitHub App and the LLM provider keys
are set in the web app and need none of this.
kubectl -n nautelle create secret generic nautelle-api \ --from-literal=DEEPL_API_KEY=… \ --from-literal=SPEECH_TO_TEXT_API_KEY=…helm upgrade nautelle oci://ghcr.io/andreybegma/charts/nautelle --version X.Y.Z \ -n nautelle --reuse-values --set existingSecret=nautelle-apiSettings that are not secret go in api.env, a map
(--set api.env.APP_TIMEZONE=Europe/Berlin). A value there wins over the one
the chart derives from nautelleUrl.
The API generates APP_ENCRYPTION_KEY, AUDIT_HMAC_KEY and LLM_SECRETS_KEY
on its first start and keeps them in secrets.json on the /data volume. A
key set in existingSecret wins over the generated one; never change one once
set. The API reads a changed Secret only when it restarts:
kubectl -n nautelle rollout restart deploy/nautelle-api.
Database and Redis
Section titled “Database and Redis”By default the chart runs PostgreSQL 16 and Redis 7 as one-pod StatefulSets with their own volumes (8 GiB and 1 GiB). To use your own instead, turn the bundled one off and give the URL, either directly or in a Secret:
postgresql: enabled: falseexternalDatabase: existingSecret: nautelle-database # a key DATABASE_URL, or existingSecretKeyredis: enabled: falseexternalRedis: url: redis://:password@redis.example.internal:6379/0externalDatabase.url and externalRedis.url work too; the chart then keeps
them in the Secret nautelle-external. The bundled database is PostgreSQL
16; yours should be the same or newer, and its user must be able to create
tables: the API migrates on every start. Redis holds BullMQ’s queues and must not evict keys
(maxmemory-policy noeviction).
The chart refuses to render a bundled and an external database at once, or neither, and the same for Redis.
Settings
Section titled “Settings”The full list, with comments, is
values.yaml. The ones you are most
likely to set:
| Value | Default | Meaning |
|---|---|---|
nautelleUrl |
http://localhost:8000 |
the address people open |
appEnv |
from nautelleUrl |
production or development |
existingSecret |
none | a Secret of extra API environment |
api.env, web.env |
{} |
plain extra environment |
api.image.tag, web.image.tag |
the chart’s appVersion |
another release’s images |
web.replicaCount, proxy.replicaCount |
1 | more pods of the stateless parts |
ingress.* |
off | see Ingress |
tunnel.token, tunnel.existingSecret |
none | see Cloudflare tunnel |
persistence.size, persistence.storageClass, persistence.existingClaim |
5 GiB, the default class | the API’s /data volume |
postgresql.enabled, externalDatabase.* |
bundled | see Database and Redis |
redis.enabled, externalRedis.* |
bundled | see Database and Redis |
nodeSelector, tolerations, affinity |
none | every pod; each component’s own wins |
<component>.resources |
small requests, memory limits | per component |
The API always runs as one pod and is replaced, not rolled: it holds the
runners’ WebSockets and the schedules in process, and its /data volume is
ReadWriteOnce. An upgrade therefore has a short gap while the new API pod
starts; the proxy holds requests for up to a minute meanwhile instead of
answering 502.
Upgrading
Section titled “Upgrading”helm upgrade nautelle oci://ghcr.io/andreybegma/charts/nautelle --version X.Y.Z \ -n nautelle --reuse-valuesThe API applies new migrations when it starts. The generated passwords stay as they are. Read the release note of each version you skip.
Uninstalling
Section titled “Uninstalling”helm uninstall nautelle -n nautelle removes the workloads and keeps the
data: the nautelle-data volume and the nautelle-bundled Secret are marked
to be kept, and Kubernetes keeps the StatefulSets’ volumes
(data-nautelle-postgresql-0, data-nautelle-redis-0). Installing again
with the same release name in the same namespace picks them up. Delete them,
or the namespace, to remove everything.
Backup
Section titled “Backup”Back up two things together: the database and the /data volume. /data
holds secrets.json, the keys that unseal the secrets in the database, and
the board card attachments; a dump without it cannot be read back. Keep the
values you installed with as well.
kubectl -n nautelle exec nautelle-postgresql-0 -- pg_dump -U nautelle nautelle > nautelle.sqlkubectl -n nautelle exec deploy/nautelle-api -- tar -cf - -C /data . > data.tarWith an external database, dump it with your own tooling. Store data.tar
apart from the dump, and as safely: whoever holds both can read every stored
secret. Redis holds only job queues and is not part of the backup.
To restore into a new install, put /data back first, then stop the API,
replace the database and start the API again, so it reads the restored keys:
kubectl -n nautelle exec -i deploy/nautelle-api -- tar -xf - -C /data < data.tarkubectl -n nautelle scale deploy/nautelle-api --replicas=0kubectl -n nautelle exec nautelle-postgresql-0 -- psql -U nautelle nautelle -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'kubectl -n nautelle exec -i nautelle-postgresql-0 -- psql -U nautelle nautelle < nautelle.sqlkubectl -n nautelle scale deploy/nautelle-api --replicas=1Pairing a runner
Section titled “Pairing a runner”In Nautelle, open Admin › Runners › Add runner, copy the code, then on the machine that holds your repositories:
brew install andreybegma/tap/nautelle-runner # or: npm i -g nautelle-runnernautelle-runner pair --server <nautelleUrl>/api --code XXXX-XXXX --allow-root ~/devnautelle-runner install-serviceThe runner connects out to <nautelleUrl>/api and keeps a WebSocket open; it
needs no access to the cluster. A runner refuses plain HTTP to any host but
localhost unless pair gets --insecure
(apps/runner). A runner behind a
kubectl port-forward stops when the port-forward does, so pair long-lived
runners against an Ingress or a tunnel.
Troubleshooting
Section titled “Troubleshooting”- The API pod stays in
Init. It waits for the bundled PostgreSQL and Redis;kubectl -n nautelle get podsshows whether they are running andkubectl -n nautelle describe pod nautelle-postgresql-0why not, usually a volume that cannot be provisioned. - The API restarts during the first start.
kubectl -n nautelle logs deploy/nautelle-api --previousshows why. With an external database, checkDATABASE_URLand that the user can create tables. - Signing in does nothing on an http:// address.
nautelleUrlmust be the address in the browser, and anhttp://one runs asdevelopment; checkappEnv. - Uploads fail with 413. The Ingress controller has a body limit of its
own; raise it to 10 MB (
proxy-body-sizewith ingress-nginx). - Runners disconnect every minute. The Ingress controller closes idle WebSockets; raise its read and send timeouts.
- A tunnel answers 502. The tunnel’s public hostname must point at
http://localhost:8080.kubectl -n nautelle logs deploy/nautelle-proxy -c cloudflaredshows the connection.