Skip to main content

Self-host on Kubernetes

fantasy-infra's manifests were written for one homelab cluster. This page lists what they assume, what you replace on your own cluster, and the order to bring it up in. Run the stack locally first, so you know your league and cookies work.

Prerequisites
  • Kubernetes 1.27 or later (the CronJobs use timeZone), with a default StorageClass that can provision ReadWriteOnce volumes.
  • kubectl with Kustomize (kubectl kustomize).
  • Cluster-admin rights to install Argo CD, KEDA and External Secrets Operator.
  • A Google OAuth client and a domain you can point at the cluster with TLS.

What does fantasy-infra assume?​

DependencyWhat it's used forIn fantasy-infraWithout the homelab
Argo CDSyncs overlays/production from Git, with prune and self-healThe Application lives in the cluster repo, not hereCreate your own Application (below), or run kubectl apply -k overlays/production
Argo CD Image UpdaterCommits new image digests to the overlaybase/argocd-image-updater/Optional. Remove it from base/kustomization.yaml and pin digests by hand, or give it a deploy key for your fork.
KEDAStarts worker pods when a queue has messagesbase/browser-worker/keda-scaledjob.yamlInstall KEDA; its RabbitMQ scaler is built in. The HTTP add-on is only for Ollama.
RabbitMQQueues between core and the workerbase/databases/rabbitmq-deployment.yaml, a plain Deployment with a 2 Gi volume and a definitions ConfigMapNothing to install; it ships in the manifests
PostgresAll statebase/databases/postgres.yaml, a StatefulSet with a 10 Gi volumeShips in the manifests. No backup job ships; follow Back up the database.
RedisLocks, the draft feed, notification de-duplicationbase/databases/redis-deployment.yaml, no persistenceShips in the manifests
External Secrets Operator and OpenBaoTurn OpenBao paths into Kubernetes Secretsoverlays/production/externalsecrets.yaml, through a ClusterSecretStore named openbaoInstall ESO and a ClusterSecretStore named openbao for any provider it supports (Vault, OpenBao, a cloud secret manager), or rename secretStoreRef in the overlay
oauth2-proxyGoogle sign-in in front of the UI and APIbase/oauth2-proxy/, NodePort 30292Keep it. Point your TLS ingress or proxy at the oauth2-proxy Service.
Ingress and TLSPublic HTTPSNone: an external reverse proxy targets the NodePort, plus a Cloudflare tunnel and Tailscale ingressesAdd your own Ingress with TLS (for example ingress-nginx and cert-manager) to oauth2-proxy:4180
Container imagescore, worker and UIghcr.io/cvillarr23/fantasy-*, pulled with ghcr-pull-secretPull them as they are, or build each repo's Dockerfile and push to your registry
Tempo, Loki, Prometheus, GrafanaTraces, logs, dashboards and alertsDashboard and alert ConfigMaps in base/; endpoints in each workload's OTEL_* envOptional. Without Tempo, the trace exporter logs errors and the services keep running.

What is specific to the original homelab?​

Change these in your fork before the first sync.

ValueWhereChange to
Namespace default and *.default.svc.cluster.local in env valuesEvery workloadKeep default, or replace every service DNS name along with the namespace
resolver 10.152.183.152 (MicroK8s CoreDNS)nginx.conf.template in the fantasy-web-ui imageYour cluster DNS Service IP (kubectl -n kube-system get svc kube-dns). It's baked into the image, so rebuild the UI image or mount your own template over /etc/nginx/templates/default.conf.template.
Pinned UI ClusterIP 10.152.183.104base/web-ui/service.yamlRemove clusterIP; it exists only for the homelab's Cloudflare tunnel
Tailscale ingressesbase/web-ui/ingress.yaml, base/databases/rabbitmq-ingress.yamlRemove them, or install the Tailscale operator
ntfy server and UI URLsbase/browser-worker/notify-config.yamlYour own ntfy server and public UI URL, or leave fantasy/ntfy unset and nothing is sent (ntfy)
CURRENT_YEAR and --season 2026overlays/production/configmap-env.yaml; the weekly and actual-scores CronJob commandsThe season you play
America/ChicagoEvery CronJob's timeZoneYour time zone, if you want different local times. ESPN's deadlines don't move.
Image digestsimages: in overlays/production/kustomization.yamlCurrent digests, or your own images

Deploy​

1. Fork fantasy-infra​

Fork cvillarr23/fantasy-infra and make the changes above. Then check that it renders:

kubectl kustomize overlays/production > /dev/null
kubectl kustomize overlays/draft-day > /dev/null

The repo's own checks are listed in Checks and tests.

2. Turn every write off for the first deploy​

The production overlay is armed for live trades. The worker ScaledJob sets TRADE_DRY_RUN=false and TRADE_SUBMIT_ENABLED=true, and no workload sets DRY_RUN, so the lineup bot is live. Until you trust the stack, edit the ScaledJob's env in your fork:

VariableSet to
DRY_RUN"true" (add it)
TRADE_DRY_RUN"true"
TRADE_SUBMIT_ENABLED"false"

Leave IDLE_EXIT_SECONDS as the first env entry; the draft-day overlay patches it by position. fantasy-infra's tests/test_worker_triggers.py asserts the armed values, so update it in the same change. The full checklist is in Run with every write disabled.

3. Install the platform​

Install Argo CD, KEDA and External Secrets Operator, following each project's instructions. Then create a ClusterSecretStore named openbao that can read the paths in the next step.

4. Seed the secrets​

Every Secret the workloads use comes from an ExternalSecret. Write these paths to your secret store before the first sync; a missing field fails that ExternalSecret, and the pods that need it don't start.

PathPropertiesBecomes
fantasy/postgrespostgres-passworddatabase-credentials
fantasy/postgres-readonlyreadonly-passwordreadonly-database-credentials
fantasy/rabbitmqrabbitmq-user, rabbitmq-passwordrabbitmq-credentials, including amqp-url
fantasy/trigger-api-keytrigger_api_key, any long random stringtrigger-api-key
fantasy/espnespn_s2, swidespn-session-secrets
fantasy/deploymentESPN_LEAGUE_ID, ESPN_TEAM_IDespn-session-secrets
fantasy/espn-session-statestate: the saved browser session JSONespn-session-state. Optional; see step 6.
fantasy/oauth2-proxyclient-id, client-secret, cookie-secretoauth2-proxy-credentials
fantasy/oauth2-proxy-deploymentauthenticated-emails-list (one email per line), redirect-url, cookie-domainoauth2-proxy-deployment-config
fantasy/ghcrusername, token with read:packagesghcr-pull-secret, in default and argocd. The template is for ghcr.io; edit it if your images live elsewhere.
fantasy/groq, fantasy/geminigroq_api_key, gemini_api_keyLLM keys. See LLM providers.
fantasy/ntfytokenntfy-credentials. Optional.
fantasy/argocd-image-updater-gitssh-private-key, ssh-public-keyThe Image Updater's deploy key. Only if you keep the Image Updater.

With OpenBao, each row is one bao kv put secret/<path> <property>=<value> …. Generate the oauth2-proxy cookie secret with openssl rand -base64 32 | tr -- '+/' '-_'. More on delivery and rotation is in Secrets and Supply private deployment values.

5. Point Argo CD at your fork​

fantasy-infra-application.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: fantasy-infra
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/<you>/fantasy-infra.git
targetRevision: HEAD
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: default
syncPolicy:
automated: {prune: true, selfHeal: true}

Apply it with kubectl apply -f fantasy-infra-application.yaml. For draft day, switch path to overlays/draft-day; see Choose the overlay.

6. Bring the data up​

The API creates the schema when it starts. The CronJobs then ingest on their schedule (Schedules). Two ingestors have no schedule, so run them once, along with a first refresh, instead of waiting for the next one:

kubectl exec deploy/fantasy-core-pipeline -- python -m src.ingestion.sync_players --force
kubectl exec deploy/fantasy-core-pipeline -- python -m src.ingestion.sync_id_crosswalk
kubectl create job --from=cronjob/fantasy-weekly-ingestor first-ingest
kubectl exec deploy/fantasy-core-pipeline -- python -m src.ingestion.sync_skill_position_crosswalk --season <season>
kubectl create job --from=cronjob/fantasy-draft-prep-refresher first-refresh

For the draft room and trade builder, save a browser session with the remote login Job; see Refreshing the login remotely. It writes fantasy/espn-session-state for you.

7. Verify​

CheckExpect
kubectl get externalsecretsEvery row SecretSynced
kubectl get podsThe API, UI, oauth2-proxy, Postgres, Redis and RabbitMQ Running
kubectl port-forward svc/fantasy-core-pipeline 8000:8000, then curl localhost:8000/v1/health"database":"connected","broker":"connected"
kubectl create job --from=cronjob/espn-session-healthcheck espn-checkThe Job completes. A failure means the cookies or IDs are wrong.
Your public URLGoogle sign-in, then the UI. Save your league on Settings.

Troubleshooting​

SymptomFix
An ExternalSecret shows SecretSyncedErrorA path or property from step 4 is missing, or the store's policy can't read it.
Pods stay ImagePullBackOffghcr-pull-secret can't read the images. Check fantasy/ghcr.
The UI loads, but every /v1 call fails with 502The UI's nginx can't resolve the API. Fix the resolver address.
Google sign-in loops or rejects youredirect-url must match the OAuth client exactly, cookie-domain must match your host, and your email must be on the list.
Worker pods never startKEDA isn't installed, or rabbitmq-trigger-auth can't read rabbitmq-credentials.

Day-two runbooks, such as expired sessions, stuck queues and rollbacks, are in Operations.