---
title: "Deploy on Kubernetes | FoxAuth"
description: "A Deployment, a Service, a provisioning Job that must finish first, and a caveat about scaling replicas."
canonical: "https://foxauth.dev/docs/deploy/kubernetes/"
lastmod: "2026-10-07T23:49:28+03:00"
---
Deploy on Kubernetes

There is no shipped chart. The manifests below are the minimum that reflects how the server actually
boots: a Job that provisions the database before the Deployment rolls, two probes answering two
different questions, and configuration out of a Secret.

SecretSection titled “Secret”

apiVersion: v1

kind: Secret

metadata:

name: oauth-server

stringData:

MONGODB_URI: mongodb+srv://user:password@cluster.example.mongodb.net

ISSUER: https://auth.example.com

DATABASE_NAME: OAuth

For PostgreSQL, the Secret holds POSTGRES_URL and ISSUER instead, since the database name is
part of that URL, and the Job’s command becomes db:setup:pg. Set one connection string, never
both. The server refuses to start when both are set instead of picking one, and that is the failure
mode you want: a server that silently chose the other database looks exactly like total data loss.
PostgreSQL covers it.

Only the connection string is a credential; keeping all of them together is simply one envFrom
instead of two. Each variable is described in the
environment variables reference. The published image tags are
listed on the GitHub releases page and
on the
container package page.

Provisioning JobSection titled “Provisioning Job”

apiVersion: batch/v1

kind: Job

metadata:

name: oauth-server-setup-<version>

spec:

backoffLimit: 2

template:

spec:

restartPolicy: Never

containers:

- name: setup

image: ghcr.io/redfox-soft/oauth-server-ts:<version>

command: ['sh', '-c', 'bun run db:setup && bun run db:migrate']

envFrom:

- secretRef:

name: oauth-server

Run this to completion before rolling the Deployment, and gate the rollout on it: an
argocd.argoproj.io/hook: PreSync annotation, a Helm pre-install,pre-upgrade hook, or simply
kubectl wait --for=condition=complete job/oauth-server-setup-<version> in your pipeline.

The reason is the same one that puts release_command in the shipped fly.toml: without the
reserved admin project, bucket and client the seed creates, GET /admin/login redirects to /auth
with a client_id no client matches and the console is unreachable, while the first-run setup page
still answers. From outside, that is easy to mistake for a login bug when the seed is what is
missing. The Job also provisions the TTL and unique-e-mail indexes the collections would otherwise
be created without.

db:migrate follows it in the same Job because the server refuses to start against a database whose
migrations have not run: a release that declares one would otherwise roll out pods that cannot boot.
Both are safe to repeat; db:setup exits non-zero when a constraint it declares is not in force, so
a failed Job is a real signal and should stop the rollout. Name the Job after the release (as above)
so each upgrade runs its own, and take a backup before one whose release notes name a migration that
cannot be undone — bun run db:migrate --plan lists what would run and exits non-zero when work is
outstanding, so a pipeline can stop there for it.

Deployment and ServiceSection titled “Deployment and Service”

apiVersion: apps/v1

kind: Deployment

metadata:

name: oauth-server

spec:

replicas: 1

selector:

matchLabels:

app: oauth-server

template:

metadata:

labels:

app: oauth-server

spec:

containers:

- name: oauth-server

image: ghcr.io/redfox-soft/oauth-server-ts:<version>

ports:

- containerPort: 3000

envFrom:

- secretRef:

name: oauth-server

readinessProbe:

httpGet:

path: /ready

port: 3000

initialDelaySeconds: 5

periodSeconds: 10

livenessProbe:

httpGet:

path: /health

port: 3000

periodSeconds: 30

resources:

requests:

cpu: 250m

memory: 512Mi

limits:

memory: 1Gi

---

apiVersion: v1

kind: Service

metadata:

name: oauth-server

spec:

selector:

app: oauth-server

ports:

- port: 80

targetPort: 3000

The two probes answer two different questions and must not share a path. /health says the process
is alive: it touches nothing outside itself, is served unconditionally with no feature flag behind
it, and is the only route the rate limiter never counts — a refused liveness probe would restart a
healthy pod. /ready says the pod can actually serve, which means reaching the datastore; when the
database goes away it fails and the pod leaves the endpoints list, and when the database comes back
it passes again within about a second, with no restart in between.

Both mistakes are worth naming, because they fail in opposite directions. Pointing the readiness
probe at /health makes an outage invisible: the process is alive, so the probe passes, and the pod
keeps receiving requests it cannot serve. Pointing the liveness probe at /ready kills every
replica for the database’s outage, in a loop, and a restart fixes nothing.

Terminate TLS at the Ingress and set ISSUER to the public https URL; the
reverse proxy page applies to an Ingress controller exactly as it
does to nginx, including the rateLimit.trustedProxy decision.

Rate limiting is per instance

From the project’s README: counting is per instance and held in memory, so with N machines serving
concurrently the effective allowance is N times the configured value, and an instance restart
clears every counter. It is a resource protection that bounds what one source can cost the server.
It is not a security boundary.
So replicas: 3 with rateLimit.strict.max at its default of 60 per minute means an origin can
reach roughly 180 requests a minute against the token endpoint. If the aggregate number is what you
care about, dividing the configured maxima by the replica count is usually the simplest answer, and
it is worth re-checking whenever you scale.
What does hold absolutely regardless of replica count are the per-identity throttles, because their
counters are persisted: verification codes cap at five attempts, resends and password-reset requests
are cooldown- and daily-capped, and the password sign-in door has its own brute-force throttle.
Those are the security boundaries, and they are unaffected by how many pods you run.

A saved setting applies to the pod that saved it

Settings are stored once, in the database, and every pod reads them at startup. A save through the
console or the management API, though, takes effect in the instance that served that request.
With several replicas behind a Service, the others keep running the values they started with until
they restart. The settings page reports what is in force on the instance answering it, and names
anything stored but not in force there.
So on more than one replica, treat a settings change as you would a configuration change: save it,
then roll the Deployment (kubectl rollout restart) to bring every pod onto the stored values. On a
single replica there is nothing to do.

Admitting only images we signedSection titled “Admitting only images we signed”

Every release is signed with cosign over its digest, keyless, and carries an SBOM and build
provenance. A cluster can therefore refuse to run anything at this image name that did not come
from this project’s release workflow. Whichever admission controller you use (sigstore
policy-controller, Kyverno’s
verifyImages, Connaisseur), a keyless policy needs the same two values, and they are what gives
such a policy any meaning:

Field
Value

OIDC issuer
https://token.actions.githubusercontent.com

Subject (regexp)
^https://github\.com/RedFox-Soft/OAuth-server\.ts/\.github/workflows/release\.yml@refs/tags/v

A policy that checks for a signature without pinning that identity admits anything anyone has
signed, which is every image on the internet. The same two values verify by hand before you roll:
cosign verify and the attestations.

Scaling notesSection titled “Scaling notes”

Sessions, tokens, codes and interactions live in the database and never in process memory, so more
than one replica is fine as far as the protocol is concerned.

Signing keys need no restart. Every issuer’s key set, the instance’s included, is read from the
database and cached for thirty seconds per replica, so a generated, promoted or retired key reaches
every replica within that time — which is why a new key cannot be promoted to signing until it has
been published for a minute.

Server settings are the exception, as the caution above says: a save applies to the replica that
served it, and the others take it on their next restart
(kubectl rollout restart deployment/oauth-server).

PreviousDeploy with Docker ComposeNextBehind a reverse proxy
