Kind deployment
This guide spins up a complete Flyte stack — the Flyte binary on a kind cluster, backed by a hosted PostgreSQL and an S3-compatible object store. kind runs anywhere Docker runs, so the same steps work on your own machine or on a cloud VM (a DigitalOcean Droplet is shown here). Either way it’s a fast way to try Flyte without running a production-grade control plane.
This runs on a single-node kind cluster with static credentials (no workload identity),
and the optional
auth setup
uses a self-signed cert the SDK only accepts via insecureSkipVerify. Use it to try
Flyte — not as a template for a production deployment. For that, see
AWS deployment. On a cloud VM, remember the stack is reachable
from the public internet: restrict ports 80/443 (and 22) to your own IP with a
cloud firewall while
you evaluate.
1. Prerequisites
Create a Droplet to host the cluster — kind needs a few GB of headroom, so pick at
least 4 vCPUs / 8 GB (e.g. s-4vcpu-8gb). Use the dashboard or
doctl:
doctl compute droplet create flyte-kind \
--image ubuntu-24-04-x64 --size s-4vcpu-8gb --region nyc1 \
--ssh-keys <your-ssh-key-id>SSH in and install Docker, kind, kubectl, and helm on the droplet:
ssh root@<droplet-ip>
# Docker
curl -fsSL https://get.docker.com | sh
# kind
curl -Lo /usr/local/bin/kind \
https://github.com/kubernetes-sigs/kind/releases/latest/download/kind-linux-amd64 \
&& chmod +x /usr/local/bin/kind
# kubectl
curl -Lo /usr/local/bin/kubectl \
"https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
&& chmod +x /usr/local/bin/kubectl
# helm
curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bashFrom here on, run every kind, kubectl, and helm command in this guide on the
droplet; only the SDK/CLI and browser run on your own machine.
2. Create the kind cluster
Create the cluster with two host-port mappings up front. kind fixes a cluster’s port mappings at creation time — they can’t be added later — so map them now even though they’re only used in the optional auth step:
kind create cluster --name flyte --config - <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080 # Traefik's web (HTTP) nodePort (step 7) — browser ingress
hostPort: 80 # reach the ingress at http://flyte.local
protocol: TCP
- containerPort: 30443 # Traefik's websecure (HTTPS) nodePort (step 7) — SDK auth
hostPort: 443 # reach the TLS ingress at https://flyte.local
protocol: TCP
EOFThis creates a single-node cluster and points your kubectl context at it. Confirm it
is up:
kubectl cluster-info --context kind-flyte30080 → 80lets the browser reach the Traefik ingress (plain HTTP) used by the optional auth setup athttp://flyte.local.30443 → 443lets the SDK/CLI reach the Traefik ingress over TLS athttps://flyte.local. The SDK only authenticates over HTTPS (see Letting the SDK authenticate), so this mapping is required if you enable ingress auth and want to submit runs from the SDK. Harmless if you never enable auth.
On a DigitalOcean droplet the same mappings bind to the droplet’s public IP —
in step 7, flyte.local points at that IP instead of 127.0.0.1, and the two ports
are open to the internet unless you restrict them with a cloud firewall (see the
warning at the top).
If you already created a plain kind create cluster --name flyte without these, delete
it (kind delete cluster --name flyte) and recreate it with the config above.
3. Deploy the dependencies
kind runs only the Flyte binary; the database and object store are hosted. You need a PostgreSQL (Supabase or another external/self-hosted instance) and an S3-compatible object store (e.g. AWS S3, Cloudflare R2, etc). The two choices are independent. Each config block below plugs into the values file in step 4.
kubectl create namespace flytePostgreSQL
Create a project at
supabase.com, then open Project
Settings → Database → Connection string and switch the tab to Session pooler.
Use that string, not the direct connection. (Another external or self-hosted
PostgreSQL works the same way — supply its host, database, user, and password, with
sslmode to match. For a DB on your host machine, use host.docker.internal as the
host. The database must already exist.)
Supabase’s direct host (db.<project-ref>.supabase.co) resolves to IPv6 only.
A kind cluster is IPv4-only, so the Flyte pod can’t reach it — wait-for-db passes
(it only probes the port) but Flyte then crash-loops on failed to connect. The
session pooler host (aws-<n>-<region>.pooler.supabase.com) has IPv4, so use it.
Use the session pooler on port 5432, not the transaction pooler (6543) —
Flyte’s migrations need session semantics.
Two things the pooler changes versus the direct string, both shown verbatim in the Session pooler tab — copy them, don’t guess:
- Username carries the project ref:
postgres.<project-ref>, not barepostgres. - The region must match your project’s:
aws-<n>-<region>.pooler.supabase.com. A mismatched region connects but is rejected withtenant/user not found.
database:
postgres:
# From the "Session pooler" connection string — copy host and username verbatim.
host: aws-1-ap-northeast-1.pooler.supabase.com # <- your project's pooler host
port: 5432 # session mode (not 6543 transaction mode)
dbname: postgres # Supabase's default database
username: postgres.<project-ref> # pooler requires the ref-qualified username
password: <your-supabase-db-password>
options: "sslmode=require" # Supabase requires TLSObject store
Both AWS S3 and Cloudflare R2 have publicly-resolvable endpoints, so the off-cluster
SDK uploads code bundles to presigned URLs directly — no nodePort or signedURL
override is needed.
Create an S3 bucket in your AWS account and an IAM user (or access key) that can read and write it.
storage:
metadataContainer: <your-s3-bucket>
userDataContainer: <your-s3-bucket>
provider: s3
providerConfig:
s3:
region: <bucket-region> # e.g. us-east-1
authType: accesskey
accessKey: <aws-access-key-id>
secretKey: <aws-secret-access-key>Create an R2 bucket and an R2 API token (Access Key ID + Secret) in the Cloudflare
dashboard. R2 is S3-compatible: point the endpoint at your account’s R2 URL and use
auto for the region.
storage:
metadataContainer: <your-r2-bucket>
userDataContainer: <your-r2-bucket>
provider: s3
providerConfig:
s3:
endpoint: https://<account-id>.r2.cloudflarestorage.com
region: auto # R2 ignores region; "auto" is conventional
authType: accesskey
accessKey: <r2-access-key-id>
secretKey: <r2-secret-access-key>
v2Signing: false4. Write the values file
Create values-local.yaml by dropping in the database and storage blocks from
step 3. It uses static access keys (no cloud workload
identity) and skips the ingress — you’ll reach Flyte with kubectl port-forward:
# values-local.yaml — local kind deployment
fullnameOverride: flyte
configuration:
# ── your PostgreSQL block (from step 3) — Supabase shown ──
database:
postgres:
host: aws-1-ap-northeast-1.pooler.supabase.com # <- your project's pooler host
port: 5432
dbname: postgres
username: postgres.<project-ref>
password: <your-supabase-db-password>
options: "sslmode=require"
# ── your object-store block (from step 3) — AWS S3 shown ──
storage:
metadataContainer: <your-s3-bucket>
userDataContainer: <your-s3-bucket>
provider: s3
providerConfig:
s3:
region: <bucket-region>
authType: accesskey
accessKey: <aws-access-key-id>
secretKey: <aws-secret-access-key>
serviceAccount:
create: true
annotations: {} # no IRSA/Workload Identity locally
ingress:
create: false # reach Flyte via port-forward insteadSwap in the R2 storage block if you chose Cloudflare R2.
5. Install Flyte
helm repo add flyteorg https://flyteorg.github.io/flyte
helm repo update
helm install flyte flyteorg/flyte-binary -n flyte -f values-local.yamlWatch the rollout:
kubectl -n flyte rollout status deploy/flyte
kubectl -n flyte get podsA wait-for-db init container blocks startup until PostgreSQL is reachable, so a pod
stuck in Init usually means the database isn’t up yet or the host/credentials are
wrong.
6. Access Flyte
Make the API reachable at localhost:8090 on the machine where you run the SDK/CLI:
Port-forward the API service:
kubectl -n flyte port-forward service/flyte-http 8090:8090The port-forward runs on the droplet, so tunnel it back to your own machine over SSH.
This single command starts the port-forward on the droplet and exposes it at
localhost:8090 locally:
ssh -L 8090:localhost:8090 root@<droplet-ip> \
kubectl -n flyte port-forward service/flyte-http 8090:8090Keep it running while you use the SDK/CLI — everything below works identically through the tunnel.
# In another terminal — list projects over the Connect (HTTP) API:
curl -s -X POST \
http://localhost:8090/flyteidl2.project.ProjectService/ListProjects \
-H 'Content-Type: application/json' -d '{}'A JSON response (rather than a connection error) confirms the binary is up and talking to its database.
Submitting runs from the SDK
Point the SDK at the API forward (or tunnel) you just started. Write
~/.flyte/config.yaml:
# ~/.flyte/config.yaml
admin:
endpoint: dns:///localhost:8090 # the port-forwarded API
insecure: True # plain HTTP, no TLS
task:
org: local
domain: development
project: flytesnacksThe code-bundle upload needs no extra setup — only the one API port-forward is required, never a second one for the object store. The S3/R2 endpoint is publicly resolvable, so the SDK uploads to the presigned URL directly.
7. Add authentication with a local IdP (optional)
The cloud worked examples in
AWS deployment gate the console with
OIDC single sign-on at the load balancer — on AWS that’s the ALB, configured through
alb.ingress.kubernetes.io/auth-* annotations. Those annotations are instructions to
the AWS Load Balancer Controller and do nothing on kind, which has no ALB.
The pattern is the same on kind; only the ingress controller changes. Here you run
Traefik and delegate auth to
oauth2-proxy: Traefik intercepts each
request through a ForwardAuth middleware, asks oauth2-proxy whether the caller is
logged in, and redirects to your IdP if not. oauth2-proxy plays the role the ALB played
— an auth proxy at the edge.
For the IdP itself, this step runs Dex inside the same kind cluster, so you can test the whole authentication flow with no cloud account and no real users — the Dex deployment itself is covered in Set up local OIDC provider (Dex).
To validate against a real OIDC provider (Okta, Google, Auth0, …) instead of the in-cluster Dex, see Set up an external OIDC provider. It follows this same step with the Dex-specific parts swapped out.
Install the ingress controller
Install Traefik with its Helm chart and expose both entrypoints on the kind node’s
nodePorts — web (HTTP) on 30080 and websecure (HTTPS) on 30443, the ports the
cluster maps to host 80 and 443 (see the warning in
step 2). The browser uses HTTP; the SDK needs HTTPS —
expose both up front so you don’t have to reinstall later.
helm repo add traefik https://traefik.github.io/charts
helm repo update
helm install traefik traefik/traefik -n traefik --create-namespace \
--set "service.type=NodePort" \
--set "ports.web.nodePort=30080" \
--set "ports.websecure.nodePort=30443"Traefik installs its CRDs (including Middleware), registers a traefik IngressClass,
and serves a default self-signed certificate on websecure. If your cluster lacks
the 30080 → 80 / 30443 → 443 mappings from
step 2,
Traefik is unreachable at flyte.local and you’ll need to recreate the cluster.
Replace the default cert with one for flyte.local
Traefik’s built-in cert is rejected by the SDK for two reasons, hit in sequence if you
only set insecureSkipVerify later:
- Its SAN is
*.traefik.default, so the hostname check fails withcertificate not valid for name "flyte.local". The SDK validates the SAN even withinsecureSkipVerify(that flag relaxes CA trust, not the hostname). - The SDK implements
insecureSkipVerifyby fetching the server’s cert chain and pinning it as the CA. A bare self-signed leaf then fails withCaUsedAsEndEntity— rustls won’t use a leaf cert as a CA.
The fix is a two-tier chain: a self-signed root CA that signs a leaf carrying
SAN=flyte.local. Traefik serves leaf + CA; the SDK pins the root as CA and validates
the leaf against it. (You can skip this if you only need the browser console — it’s the
SDK that requires a trusted-chain TLS cert.)
# 1. Root CA (CA:TRUE)
openssl req -x509 -nodes -newkey rsa:2048 -days 3650 \
-keyout ca.key -out ca.crt -subj "/CN=flyte-local-ca" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
# 2. Leaf key + CSR
openssl req -nodes -newkey rsa:2048 -keyout leaf.key -out leaf.csr \
-subj "/CN=flyte.local"
# 3. CA signs the leaf (CA:FALSE, SAN=flyte.local, server auth)
openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 3650 -out leaf.crt \
-extfile <(printf "subjectAltName=DNS:flyte.local\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth")
# 4. Secret holds the full chain (leaf + CA) so Traefik serves both
cat leaf.crt ca.crt > fullchain.crt
kubectl -n traefik create secret tls flyte-local-tls \
--cert=fullchain.crt --key=leaf.keyPoint Traefik’s cluster-wide default cert at that secret with a TLSStore named
default (the only name Traefik honours for the fallback cert), then restart Traefik:
kubectl apply -f - <<'EOF'
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
name: default
namespace: traefik
spec:
defaultCertificate:
secretName: flyte-local-tls
EOF
kubectl -n traefik rollout restart deploy/traefikinsecureSkipVerifyThis cert chains to a self-signed root the SDK doesn’t trust, so the SDK config
below still sets insecureSkipVerify to accept
it — inherent to any private/self-signed CA (AWS Private CA included), not a Traefik
limitation. To drop it entirely, either install ca.crt into each client’s system
trust store, or front Flyte with a publicly-resolvable domain and a publicly-trusted
cert (e.g. Traefik’s ACME / Let’s Encrypt resolver — the cloud ALB’s ACM equivalent).
Neither is possible for a purely-local flyte.local. On a DigitalOcean droplet the
domain route is attainable — point a real DNS record at the droplet and substitute
that hostname for flyte.local throughout — but this guide sticks with the self-signed
chain so the local and cloud-VM paths stay identical.
Deploy Dex
Deploy Dex into the cluster and route its issuer URL (http://flyte.local/dex)
through Traefik, following
Set up local OIDC provider (Dex). Come back here once the discovery
document resolves through Traefik:
curl -s http://flyte.local/dex/.well-known/openid-configuration | head
# → a JSON document with "issuer":"http://flyte.local/dex"Deploy oauth2-proxy
Give oauth2-proxy the Dex client details and a random cookie secret. --set-xauthrequest
makes it emit the X-Auth-Request-* headers Traefik forwards downstream, and
--reverse-proxy tells it to trust the forwarded host/proto from Traefik.
skip-jwt-bearer-tokens, oidc-extra-audience, and bearer-token-login-fallback let
the SDK/CLI authenticate too (not just the browser) — set them now so you don’t have
to upgrade later. The hostAliases setting is the one addition an in-cluster Dex needs —
see the warning below for why:
# 32-byte cookie secret. Must decode to 16/24/32 bytes — head -c 32 trims the
# base64 string, since a raw 44-char value fails oauth2-proxy's length check.
COOKIE_SECRET=$(openssl rand -base64 32 | head -c 32)
# Dex's issuer is flyte.local, which the pod can't otherwise resolve — point it at Traefik.
TRAEFIK_IP=$(kubectl -n traefik get svc traefik -o jsonpath='{.spec.clusterIP}')
helm repo add oauth2-proxy https://oauth2-proxy.github.io/manifests
helm repo update
helm install oauth2-proxy oauth2-proxy/oauth2-proxy -n flyte \
--set config.clientID='oauth2-proxy' \
--set config.clientSecret='oauth2-proxy-secret' \
--set config.cookieSecret="$COOKIE_SECRET" \
--set extraArgs.provider=oidc \
--set extraArgs.oidc-issuer-url='http://flyte.local/dex' \
--set extraArgs.upstream='static://202' \
--set extraArgs.reverse-proxy='true' \
--set extraArgs.set-xauthrequest='true' \
--set extraArgs.email-domain='*' \
--set extraArgs.cookie-secure='false' \
--set extraArgs.skip-jwt-bearer-tokens='true' \
--set extraArgs.oidc-extra-audience='flytectl' \
--set extraArgs.bearer-token-login-fallback='false' \
--set "hostAliases[0].ip=$TRAEFIK_IP" \
--set "hostAliases[0].hostnames[0]=flyte.local"A few of these flags deserve explanation:
cookie-secure='false'— the browser flow runs over local HTTP, not HTTPS.skip-jwt-bearer-tokens='true'— the browser path uses the session cookie, but the SDK sends anAuthorization: BearerJWT instead. This makes oauth2-proxy verify that JWT against Dex’s JWKS and pass it through.oidc-extra-audience='flytectl'— must list the public client ID the SDK uses (theflytectlstatic client from the Dex configuration; also theflyteClient.clientIdyou’ll advertise inauthMetadatabelow) — its tokens carry that as their audience. The flag is singular —oidc-extra-audiences(plural) is not valid and crash-loops oauth2-proxy withunknown flag.bearer-token-login-fallback='false'— an invalid token gets a403, not an HTML login page the SDK can’t parse.
Without the SDK flags, the SDK is rejected and flyte.run fails the upload with
Unauthorized. See
Letting the SDK authenticate for
the rest of the SDK setup.
hostAliases is required for DexUnlike an external IdP, Dex’s issuer is flyte.local — a name that resolves on your
host (via /etc/hosts) but not inside the cluster, where CoreDNS doesn’t know it.
Without help, oauth2-proxy hangs on Performing OIDC Discovery... at startup and
CrashLoopBackOffs. The hostAliases flag above adds flyte.local → Traefik's ClusterIP to the pod’s /etc/hosts, so flyte.local/dex resolves to the same issuer
from both the pod and the browser, as OIDC requires.
This pins the current ClusterIP. If the Traefik service is recreated with a new IP,
helm upgrade oauth2-proxy with the new value.
Create the ForwardAuth middleware
Two Traefik Middleware objects: one sends each request to oauth2-proxy for a verdict
and forwards the identity headers; the other catches the 401 an unauthenticated
request gets and redirects it to the oauth2-proxy sign-in page.
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth2-auth
namespace: flyte
spec:
forwardAuth:
address: http://oauth2-proxy.flyte.svc.cluster.local/oauth2/auth
trustForwardHeader: true
# Forwarded to Flyte; these feed executed_by run attribution.
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: oauth2-signin
namespace: flyte
spec:
errors:
status:
- "401"
service:
name: oauth2-proxy
port: 80
query: "/oauth2/sign_in?rd={url}"Create the Flyte ingress with the middleware
Re-render Flyte with the ingress enabled and pointed at Traefik. The
traefik.ingress.kubernetes.io/router.middlewares annotation chains both middlewares
onto every route — this is the Traefik equivalent of the ALB’s auth-type: oidc. The
reference format is <namespace>-<name>@kubernetescrd:
# add to values-local.yaml, replacing the `ingress.create: false` block
ingress:
create: true
host: flyte.local # add "127.0.0.1 flyte.local" to /etc/hosts
ingressClassName: traefik
httpAnnotations:
traefik.ingress.kubernetes.io/router.middlewares: flyte-oauth2-signin@kubernetescrd,flyte-oauth2-auth@kubernetescrdYou also need a route that sends /oauth2 to oauth2-proxy itself so the sign-in
redirect resolves. Apply it alongside the Flyte release:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: oauth2-proxy
namespace: flyte
spec:
ingressClassName: traefik
rules:
- host: flyte.local
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: oauth2-proxy
port:
number: 80After helm upgrade flyte … -f values-local.yaml, make flyte.local resolve to
Traefik from the machine your browser and SDK run on (do this once):
Traefik’s node ports are mapped to your own host, so point flyte.local at loopback:
echo "127.0.0.1 flyte.local" | sudo tee -a /etc/hostsTraefik’s node ports are bound to the droplet’s public IP, so point flyte.local there
in your own machine’s /etc/hosts (not the droplet’s):
echo "<droplet-ip> flyte.local" | sudo tee -a /etc/hostsEvery other flyte.local reference in this step (Dex issuer, redirect URIs, cert SAN,
ingress host) stays exactly the same — only this mapping differs. Alternatively, create
a real DNS A record pointing at the droplet and substitute that hostname everywhere
flyte.local appears.
Opening the console by raw IP won’t work in its place — Traefik has no route for that
host, and the OIDC issuer is flyte.local, so login fails on an issuer mismatch. Then
open http://flyte.local/v2 — Traefik bounces you through Dex and back into the console.
Split the API and discovery paths off the browser middleware
This gates the browser correctly, but the same oauth2-signin redirect on every
path breaks the SDK (the cloud walkthrough avoids this by splitting into three ingresses —
ingress / apiJwtIngress / wellknownIngress — since ALB can’t mix cookie-OIDC and JWT
auth on one). Two path groups need different handling:
- Auth-discovery (
AuthMetadataService,IdentityService) — the SDK reads these before it has a token, so they must bypass auth. Gated, they return atext/plain401 that ConnectRPC reports asUNAVAILABLE, and the SDK never starts login. - The
flyteidl2.*API — needsoauth2-auth(Bearer validation) but notoauth2-signin, so an unauthenticated call gets a clean gRPC 401 the SDK retries after login, not sign-in HTML.
Add two higher-priority IngressRoutes — Traefik matches the highest priority first,
so these win over the flyte-http Ingress for their paths:
kubectl apply -f - <<'EOF'
# Discovery — highest priority, NO middleware. The SDK reads these before it has a
# token; they must reach Flyte directly so it returns the real metadata (issuer,
# client ID), not oauth2-proxy's unparseable 401. (Equivalent of wellknownIngress.)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: flyte-auth-discovery
namespace: flyte
spec:
entryPoints: [web, websecure]
routes:
- kind: Rule
priority: 300
match: Host(`flyte.local`) && (PathPrefix(`/flyteidl2.auth.AuthMetadataService`) || PathPrefix(`/flyteidl2.auth.IdentityService`))
services:
- name: flyte-http
port: 8090
scheme: h2c # gRPC over cleartext HTTP/2 to the backend
---
# API — oauth2-auth only (validates Bearer tokens), no oauth2-signin redirect, so an
# unauthenticated call gets a clean 401 the SDK can act on. (Equivalent of apiJwtIngress.)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: flyte-api-bearer
namespace: flyte
spec:
entryPoints: [web, websecure]
routes:
- kind: Rule
priority: 100
match: Host(`flyte.local`) && PathPrefix(`/flyteidl2.`)
middlewares:
- name: oauth2-auth
services:
- name: flyte-http
port: 8090
scheme: h2c
EOFNow flyte.local routes three ways by precedence: discovery (300) bypasses auth, the
API (100) requires a Bearer token, and everything else — the /v2 console — falls
through to the flyte-http Ingress with the full browser middleware chain. Verify the
discovery path returns JSON rather than oauth2-proxy’s 401:
curl -s -X POST --resolve flyte.local:443:127.0.0.1 -k \
https://flyte.local/flyteidl2.auth.AuthMetadataService/GetPublicClientConfig \
-H 'Content-Type: application/json' -d '{}' | head -c 120
# → {"clientId":"flytectl", ...} (JSON, not "Unauthorized")Verify the browser flow
With Dex, oauth2-proxy, and the Flyte ingress all in place, check the flow from the
command line before opening a browser. These use curl --resolve to point flyte.local
at the Traefik node port, so they work even without the /etc/hosts entry (the
browser still needs it):
--resolve on a DigitalOcean dropletThe --resolve flyte.local:<port>:127.0.0.1 flags in this guide assume the curl runs
on the machine hosting kind. That’s still true on a droplet — run them in your SSH
session and 127.0.0.1 works as-is. To run them from your own machine instead,
substitute the droplet’s public IP for 127.0.0.1.
# 1. The console is gated — an unauthenticated request is rejected by the auth middleware:
curl -s -o /dev/null -w "%{http_code}\n" --resolve flyte.local:80:127.0.0.1 \
http://flyte.local/v2
# → 401 (the oauth2-auth ForwardAuth middleware rejects it; a browser is then
# redirected to sign-in by the oauth2-signin error middleware)
# 2. The sign-in page is served:
curl -s -o /dev/null -w "%{http_code}\n" --resolve flyte.local:80:127.0.0.1 \
"http://flyte.local/oauth2/sign_in?rd=http://flyte.local/v2"
# → 200
# 3. Starting login redirects all the way to Dex's login page:
curl -s -o /dev/null -w "%{url_effective}\n" -L --max-redirs 5 \
--resolve flyte.local:80:127.0.0.1 "http://flyte.local/oauth2/start?rd=http://flyte.local/v2"
# → http://flyte.local/dex/auth/local/login?... (oauth2-proxy → Dex)Traefik’s oauth2-signin middleware turns the 401 into a sign-in redirect via its
errors handler, which a browser follows automatically. On a plain curl you see the
raw 401 — that still confirms the request is being gated. The redirect itself is
verified by checks 2 and 3.
Then open http://flyte.local/v2 in a browser, log in as [email protected] /
password, and you should land in the console. The X-Auth-Request-Email header Dex
supplies flows through oauth2-proxy to Flyte and populates executed_by on runs you
create — see
Authentication and SSO.
Letting the SDK/CLI authenticate (with auth enabled)
The browser flow above works over plain HTTP, but the SDK does not. Like the cloud
walkthrough in
Authentication and SSO — which requires an HTTPS
listener because “OIDC auth only applies to HTTPS rules” — the SDK path here only works
over TLS. The reason is in the SDK itself: it attaches its auth interceptors (PKCE
browser login, token injection) only when the client uses TLS. With insecure: True
it assumes “plaintext endpoint ⇒ no auth server” and skips authentication entirely, so an
SDK pointed at http://flyte.local sends no token, oauth2-proxy rejects every call,
and flyte.run fails the code-bundle upload with Unauthorized — without ever opening a
browser login.
With oauth2-proxy configured for Bearer tokens (the SDK flags in
Deploy oauth2-proxy), Traefik’s TLS listener exposed, and the
flyte.local cert in place (
Install the ingress controller),
three things remain: advertise Dex in Flyte’s auth metadata, point the SDK at the HTTPS
endpoint, and make Flyte able to reach Dex’s discovery document.
Advertise Dex to the SDK/CLI
oauth2-proxy gates the browser path, but the SDK/CLI discover where to log in from
Flyte’s
auth metadata. Point it at
Dex using the public flytectl client from the
Dex configuration, then helm upgrade:
# add to values-local.yaml
flyte-core-components:
runs:
authMetadata:
externalAuthServerBaseUrl: http://flyte.local/dex
flyteClient:
clientId: flytectl
redirectUri: http://localhost:53593/callback
scopes:
- openid
- profile
- offline_accessThis is the same authMetadata block as a real IdP — only the issuer URL points at the
in-cluster Dex. V2 has no auth server of its own; it just advertises Dex.
Point the SDK at the HTTPS endpoint
In ~/.flyte/config.yaml, reach Flyte over TLS and accept the self-signed CA from the
Traefik install:
admin:
endpoint: dns:///flyte.local # must match SelectCluster's clusterEndpoint (no :80)
insecure: False # use TLS — the SDK only authenticates over TLS
insecureSkipVerify: True # accept the self-signed CA (see the note above)
authType: Pkce
task:
org: local
domain: development
project: flytesnacksinsecureSkipVerify (camelCase)The SDK reads admin.insecureSkipVerify. The snake_case insecure_skip_verify is
silently ignored, so the SDK keeps full verification and fails on the self-signed cert.
The SDK also reads the project-local .flyte/config.yaml (in the directory you run
from) before ~/.flyte/config.yaml — make sure the flag is in whichever file actually
applies.
endpoint must match what SelectCluster returnsBefore uploading, the SDK calls SelectCluster; if the returned clusterEndpoint
differs from your admin.endpoint, it builds a separate per-cluster session for the
upload that may skip auth. Check the value and match it exactly:
curl -s -X POST --resolve flyte.local:443:127.0.0.1 -k \
https://flyte.local/flyteidl2.cluster.ClusterService/SelectCluster \
-H 'Content-Type: application/json' \
-d '{"operation":"OPERATION_CREATE_UPLOAD_LOCATION","project":"flytesnacks","domain":"development","org":"local"}'
# → {"clusterEndpoint":"https://flyte.local"} ⇒ endpoint: dns:///flyte.local (no :443)Let Flyte reach Dex’s discovery document
Because Dex runs in-cluster with an issuer that only your host’s /etc/hosts knows
about, two fixes are needed so Flyte’s GetOAuth2Metadata — which fetches the IdP’s
discovery document to tell the SDK where to log in — actually succeeds. (An external
IdP is publicly resolvable and serves the standard discovery paths, so it needs neither —
see
Set up an external OIDC provider.)
(a) DNS. Flyte fetches http://flyte.local/dex/..., but flyte.local isn’t
resolvable inside the cluster (it’s only in your host’s /etc/hosts), so the fetch times
out. Point flyte.local at Traefik’s ClusterIP from inside the Flyte pod:
TRAEFIK_IP=$(kubectl -n traefik get svc traefik -o jsonpath='{.spec.clusterIP}')
helm upgrade flyte flyteorg/flyte-binary -n flyte -f values-local.yaml \
--set "deployment.extraPodSpec.hostAliases[0].ip=$TRAEFIK_IP" \
--set "deployment.extraPodSpec.hostAliases[0].hostnames[0]=flyte.local"(b) Discovery path. Flyte fetches the RFC 8414 path
/.well-known/oauth-authorization-server, but Dex only serves the OIDC path
/.well-known/openid-configuration (the former returns 404). The two carry the same
endpoints, so rewrite one to the other at Traefik:
kubectl apply -f - <<'EOF'
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: dex-wellknown-rewrite
namespace: flyte
spec:
replacePathRegex:
regex: ^/dex/\.well-known/oauth-authorization-server$
replacement: /dex/.well-known/openid-configuration
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: dex-oauth-metadata
namespace: flyte
spec:
entryPoints: [web, websecure]
routes:
- kind: Rule
priority: 200 # match before the /dex Ingress
match: Host(`flyte.local`) && Path(`/dex/.well-known/oauth-authorization-server`)
middlewares:
- name: dex-wellknown-rewrite
services:
- name: dex
port: 5556
EOFVerify both fixes landed — this should return JSON, not a 404 or a timeout:
curl -s -X POST --resolve flyte.local:443:127.0.0.1 -k \
https://flyte.local/flyteidl2.auth.AuthMetadataService/GetOAuth2Metadata \
-H 'Content-Type: application/json' -d '{}' | head -c 200First clear any stale SDK token from a previous cluster. The SDK caches OAuth
tokens in the keyring (macOS Keychain), keyed by endpoint host — kind delete cluster
doesn’t wipe them. Dex’s storage: memory mints new signing keys on every restart, so
an old token fails oauth2-proxy’s signature check with 403 Forbidden on
SelectCluster and no browser opens. Clear it after any cluster/Dex recreate:
# macOS; "not found" is fine. Linux: keyring del flyte.local access_token / refresh_token
for k in access_token refresh_token; do security delete-generic-password -s flyte.local -a "$k" 2>/dev/null; doneThen flyte.run opens a browser to Dex, you log in ([email protected] / password),
and the SDK submits the run with the resulting token.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
oauth2-proxy CrashLoopBackOff, logs stuck on Performing OIDC Discovery... |
The pod can’t resolve flyte.local in-cluster. Add the hostAliases from the
oauth2-proxy install mapping flyte.local to Traefik’s ClusterIP. |
oauth2-proxy CrashLoopBackOff, logs show could not fetch .well-known |
oauth2-proxy can’t reach the issuer. Confirm the discovery curl in
Route the issuer path returns the discovery doc and that oidc-issuer-url matches issuer in the Dex config exactly. |
Browser: Unregistered redirect_uri / redirect_uri did not match |
The oauth2-proxy static client’s redirectURIs must list the callback for the scheme you open the console with — http://flyte.local/oauth2/callback and https://flyte.local/oauth2/callback (opening /v2 over TLS uses the https one). List both. |
| Login succeeds but loops back to sign-in | Issuer mismatch between what the browser saw and what oauth2-proxy validated. Both must be http://flyte.local/dex — not a service name, not localhost. |
flyte.run fails the upload with Unauthorized, no browser opens |
The SDK is on plain HTTP (insecure: True) and skipped auth. Use insecure: False + https://flyte.local. |
InitializationError: Service is unavailable / EndpointUnavailable, no browser opens |
The SDK couldn’t reach the API or discovery paths. Two common causes: the auth-discovery/API paths are still behind oauth2-signin (apply the two IngressRoutes in
Split the API and discovery paths); or the TLS cert is rejected (next two rows). |
invalid peer certificate: ... not valid for name "flyte.local" |
Traefik is serving its default cert (SAN=*.traefik.default). Apply the flyte.local cert in
Replace the default cert. |
invalid peer certificate: ... CaUsedAsEndEntity |
The cert is a bare self-signed leaf; the SDK pins it as a CA. Use the two-tier root-CA-signs-leaf chain in Replace the default cert. |
Connection refused to https://flyte.local |
No TLS listener — Traefik’s websecure isn’t exposed, or the cluster lacks the 30443 → 443 mapping. See
step 2 / the
Traefik install. |
| Upload still 401 after a successful browser login | oauth2-proxy rejects the Bearer token. Confirm skip-jwt-bearer-tokens=true and oidc-extra-audience=flytectl (
oauth2-proxy install); check its logs for audience ... does not match. |
403 Forbidden on SelectCluster, no browser opens (oauth2-proxy logs failed to verify id token signature) |
Stale cached token; Dex’s in-memory keys changed on restart. Clear the keyring tokens (block above) and rerun. |
GetOAuth2Metadata returns ... 404 ... oauth-authorization-server |
The well-known rewrite isn’t applied — fix (b) in Let Flyte reach Dex’s discovery document. |
GetOAuth2Metadata times out (context deadline exceeded) |
Flyte can’t resolve flyte.local in-cluster. Apply the hostAliases — fix (a) in
Let Flyte reach Dex’s discovery document. |
8. Load a local image into kind (optional)
kind nodes can’t pull from the host’s Docker daemon. If you build a custom task or Flyte image, load it into the cluster so pods can run it without a registry:
kind load docker-image <your-image>:<tag> --name flyteOn a DigitalOcean droplet, the image must be in the droplet’s Docker daemon first —
either build it there, or ship it from your machine with
docker save <image> | ssh root@<droplet-ip> docker load.
Reference that exact <your-image>:<tag> in your task config; with the image already
present, the default IfNotPresent pull policy won’t try to fetch it from a registry.
9. Tear down
Delete the cluster and Flyte in one command:
kind delete cluster --name flyteOn DigitalOcean, also destroy the droplet so it stops billing:
doctl compute droplet delete flyte-kindThe hosted PostgreSQL and S3/R2 bucket are untouched — clean those up in their own consoles.
When you’re ready to deploy to a real cluster, continue to AWS deployment.