← FlyteCONTENT HISTORY

Update to Flyte

Snapshot Sep 30, 2026 · 22:59 UTC · version 1.0.1

Collection source: not recorded for this historical snapshot.

WHAT CHANGED · RULE-BASED ANALYSIS

First saved snapshot

No earlier snapshot is available to establish a change.

Compare saved observations

Download comparison JSON
Full technical diff · 0 changed fields
Full snapshot data
{
  "description": "Deploy a complete Flyte stack (flyte-binary + a hosted PostgreSQL + an object store) onto a kind cluster, running on the user's own machine or a DigitalOcean VM (droplet). PostgreSQL is hosted (Supabase or external); the object store is AWS S3 or Cloudflare R2. Use when the user wants to run Flyte on kind — either reusing an existing kind cluster or creating a new one. For evaluation only (no TLS/auth on the base deployment).",
  "included_files": [],
  "name": "deploy-flyte-kind",
  "skill_md_contents": "---\nname: deploy-flyte-kind\ndescription: Deploy a complete Flyte stack (flyte-binary + a hosted PostgreSQL + an object store) onto a kind cluster, running on the user's own machine or a DigitalOcean VM (droplet). PostgreSQL is hosted (Supabase or external); the object store is AWS S3 or Cloudflare R2. Use when the user wants to run Flyte on kind — either reusing an existing kind cluster or creating a new one. For evaluation only (no TLS/auth on the base deployment).\n---\n\n# Deploy Flyte to a kind cluster\n\nStand up Flyte on a [kind](https://kind.sigs.k8s.io/) cluster: the flyte-binary\nplus a hosted PostgreSQL and an S3-compatible object store. For **evaluation\nonly** — no TLS, no auth, static credentials.\n\nkind runs anywhere Docker runs, so the cluster can live on the user's **own\nmachine** (default) or a **DigitalOcean VM** (droplet) — the host is a choice\nmade in Step 0.\n\nThe PostgreSQL and object store are independent choices the user makes in Step 2:\n\n- **PostgreSQL** — **Supabase** or another external/self-hosted PostgreSQL.\n- **Object store** — **AWS S3** or **Cloudflare R2**.\n\nBoth are hosted; kind runs only the flyte-binary. The user supplies connection\ndetails for each.\n\n## Step 0: Choose the host, check prerequisites, and check for an existing cluster\n\n**First, ask the user where kind should run** (use `AskUserQuestion`):\n\n- **the user's own machine** (default), or\n- a **DigitalOcean VM** (droplet) — the only cloud-VM host this skill supports.\n\nDo **not** offer or hand-roll AWS EC2 or GCP VM setups; for a real cloud\ndeployment, point the user at the AWS deployment skill instead.\n\nIf the user picks the droplet, **every `kind`, `kubectl`, and `helm` command\nbelow runs on the droplet** (over SSH) — only the SDK/CLI and browser run on the\nuser's own machine. Provision it and install the tools there first (needs a few\nGB of headroom for kind, so ≥ 4 vCPU / 8 GB):\n\n```bash\n# create the droplet (dashboard or doctl)\ndoctl compute droplet create flyte-kind \\\n  --image ubuntu-24-04-x64 --size s-4vcpu-8gb --region nyc1 \\\n  --ssh-keys <your-ssh-key-id>\n\n# SSH in and install Docker, kind, kubectl, helm ON the droplet\nssh root@<droplet-ip>\ncurl -fsSL https://get.docker.com | sh\ncurl -Lo /usr/local/bin/kind \\\n  https://github.com/kubernetes-sigs/kind/releases/latest/download/kind-linux-amd64 \\\n  && chmod +x /usr/local/bin/kind\ncurl -Lo /usr/local/bin/kubectl \\\n  \"https://dl.k8s.io/release/$(curl -Ls https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl\" \\\n  && chmod +x /usr/local/bin/kubectl\ncurl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash\n```\n\n> [!WARNING] A cloud VM is exposed to the internet\n> On a droplet the stack is reachable from the public internet. Restrict ports\n> `80`, `443`, and `22` to the user's own IP with a\n> [cloud firewall](https://docs.digitalocean.com/products/networking/firewalls/)\n> while they evaluate.\n\nThen verify the required tools **on whichever host runs kind** (run the check on\nthe droplet over SSH if that's the host), and decide whether to create a cluster\nor reuse one:\n\n```bash\nfor t in docker kind kubectl helm; do command -v $t >/dev/null || echo \"MISSING: $t\"; done\nkind get clusters\n```\n\n- If any tool is `MISSING`, stop and tell the user to install it\n  ([docker](https://docs.docker.com/get-docker/),\n  [kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation),\n  [kubectl](https://kubernetes.io/docs/tasks/tools/),\n  [helm](https://helm.sh/docs/intro/install/)).\nRead the `kind get clusters` output literally — it is the exact list of existing\ncluster names, one per line. Do **not** assume a `flyte` cluster exists because\nthis guide uses that name; only treat it as present if `flyte` appears verbatim\nin the output. A line like `kind` is a cluster named `kind`, not `flyte`.\n\n- **`flyte` is in the list** → reuse it: skip Step 1, use `--context kind-flyte`\n  below.\n- **`flyte` is not in the list** → create it in Step 1. (Don't silently reuse a\n  differently-named cluster; if the user wants to reuse one, confirm its name\n  and substitute it into `--context kind-<name>` everywhere below.)\n- **No clusters at all** → create one in Step 1.\n\n## Step 1: Create the kind cluster (skip if reusing)\n\nCreate the cluster with **two** host-port mappings. kind fixes a cluster's port\nmappings **at creation time** — they can't be added later — so map them both now\nregardless of whether auth is added:\n\n- **`30080 → 80`** lets the **browser** reach the Traefik ingress (plain HTTP) used\n  by the optional auth section at `http://flyte.local`.\n- **`30443 → 443`** lets the **SDK/CLI** reach the Traefik ingress over **TLS** at\n  `https://flyte.local`. The SDK only authenticates over HTTPS (see the SDK-auth\n  part of the auth section), so this is required if the user enables auth *and*\n  wants to submit runs from the SDK. Harmless otherwise.\n\n```bash\nkind create cluster --name flyte --config - <<'EOF'\nkind: Cluster\napiVersion: kind.x-k8s.io/v1alpha4\nnodes:\n  - role: control-plane\n    extraPortMappings:\n      - containerPort: 30080   # Traefik web (HTTP) nodePort (auth section)\n        hostPort: 80           # reach the ingress at http://flyte.local\n        protocol: TCP\n      - containerPort: 30443   # Traefik websecure (HTTPS) nodePort — SDK auth\n        hostPort: 443          # reach the TLS ingress at https://flyte.local\n        protocol: TCP\nEOF\n```\n\nMap both even if the user isn't sure about auth — they're harmless if auth is never\nadded. **If a plain `kind create cluster --name flyte` was already made without\nthese, delete it (`kind delete cluster --name flyte`) and recreate with the config\nabove** — they can't be added in place.\n\nOn a **DigitalOcean droplet** these mappings bind to the droplet's **public IP** —\nso in the auth section `flyte.local` points at that IP instead of `127.0.0.1`, and\nthe two ports are open to the internet unless restricted by the cloud firewall (see\nthe warning in Step 0).\n\n```bash\nkubectl cluster-info --context kind-flyte\n```\n\n## Step 2: Choose and deploy dependencies (PostgreSQL + object store)\n\nkind runs only the flyte-binary; the database and object store are hosted. **Before\nwriting the values file, ask the user two questions** (use the `AskUserQuestion`\ntool — one question per choice, or one multi-part prompt):\n\n1. **PostgreSQL** — *Supabase* or *another external/self-hosted PostgreSQL*?\n2. **Object store** — *AWS S3* or *Cloudflare R2*?\n\nCollect the connection details for each — the user can **type them or paste a\nscreenshot** of the relevant console page (Supabase Project Settings → Database; the\nAWS S3 / Cloudflare R2 credentials page). Read the values out of the screenshot; if\nanything required is missing or unreadable, ask for just that field. **Never invent\nor guess a host, bucket name, key, or password** — if it isn't provided, stop and\nask. Required fields per choice are listed below.\n\nCreate the namespace:\n\n```bash\nkubectl create namespace flyte\n```\n\n### PostgreSQL\n\n**Supabase** — nothing to install in the cluster. **Must use the session pooler, not\nthe direct connection** (see the warning below). Have the user open **Project Settings\n→ Database → Connection string**, switch the tab to **Session pooler**, and type or\nscreenshot that string. Collect from it:\n\n- host — `aws-<n>-<region>.pooler.supabase.com` (the pooler host, *not* `db.<ref>.supabase.co`)\n- database name (Supabase default: `postgres`)\n- username — `postgres.<project-ref>` (pooler username carries the project ref)\n- password\n\nSupabase requires TLS, so use `sslmode=require` in Step 3.\n\n> [!WARNING] Why the session pooler, not the direct connection\n> The direct host `db.<ref>.supabase.co` resolves to **IPv6 only**; kind is IPv4-only,\n> so the Flyte pod can't reach it. `wait-for-db` still passes (it only probes the\n> port via `pg_isready`), then Flyte crash-loops on `failed to connect`. The session\n> pooler host has IPv4. Use port **`5432` (session)**, not `6543` (transaction) —\n> Flyte's migrations need session semantics. **Read the host and username straight\n> from the Session pooler tab; never reconstruct them** — a wrong region connects but\n> is rejected with `tenant/user not found`, and the pooler username must be\n> `postgres.<ref>`, not bare `postgres`.\n\n**Reusing your own PostgreSQL** is also fine: take the same fields as Supabase. For\na DB on the host machine use `host.docker.internal` as the host. The database must\nalready exist.\n\n### Object store\n\n**AWS S3** — nothing to install. The user creates the bucket and an access key in\ntheir AWS account; collect (type or screenshot):\n\n- bucket name\n- region (e.g. `us-east-1`)\n- access key ID\n- secret access key\n\n**Cloudflare R2** — nothing to install. The user creates an R2 bucket and an R2 API\ntoken in the Cloudflare dashboard; collect (type or screenshot):\n\n- bucket name\n- account endpoint (`https://<account-id>.r2.cloudflarestorage.com`)\n- access key ID\n- secret access key\n\nBoth endpoints are publicly resolvable, so no `signedURL` override is needed — the\nSDK uploads code bundles straight to the bucket.\n\n## Step 3: Write the values file\n\nAssemble `values-local.yaml` from the `database` and `storage` blocks matching the\nStep 2 choices. The skeleton:\n\n```yaml\n# values-local.yaml — local kind deployment\nfullnameOverride: flyte\n\nconfiguration:\n  # << database block — Supabase/external below >>\n  # << storage block — S3 or R2 below >>\n  # << inline block — task-pod storage credentials + storagePrefix, REQUIRED (below) >>\n\nserviceAccount:\n  create: true\n  annotations: {}\n\ningress:\n  create: false\n```\n\n### Database block\n\n**Supabase (or other external PostgreSQL)** — fill in the collected values. For\nSupabase, host and username come from the **Session pooler** connection string:\n\n```yaml\n  database:\n    postgres:\n      host: aws-<n>-<region>.pooler.supabase.com   # session pooler host (has IPv4)\n      port: 5432                        # session mode (not 6543 transaction mode)\n      dbname: postgres                  # Supabase default\n      username: postgres.<project-ref>  # pooler username carries the project ref\n      password: <supabase-db-password>\n      options: \"sslmode=require\"        # Supabase requires TLS\n```\n\n### Storage block\n\n**AWS S3** — fill in the collected values:\n\n```yaml\n  storage:\n    metadataContainer: <s3-bucket>\n    userDataContainer: <s3-bucket>\n    provider: s3\n    providerConfig:\n      s3:\n        region: <bucket-region>\n        authType: accesskey\n        accessKey: <aws-access-key-id>\n        secretKey: <aws-secret-access-key>\n```\n\n**Cloudflare R2** — fill in the collected values:\n\n```yaml\n  storage:\n    metadataContainer: <r2-bucket>\n    userDataContainer: <r2-bucket>\n    provider: s3\n    providerConfig:\n      s3:\n        endpoint: https://<account-id>.r2.cloudflarestorage.com\n        region: auto                    # R2 ignores region; \"auto\" is conventional\n        authType: accesskey\n        accessKey: <r2-access-key-id>\n        secretKey: <r2-secret-access-key>\n        v2Signing: false\n```\n\n### Inline block — task-pod storage credentials + storagePrefix (required)\n\nThe `storage` block above configures only the **control plane**. Two more settings\nare required for tasks to actually run — without them the API works but **every task\nfails**:\n\n- **Task pods get no object-store credentials.** The task-side SDK reads static\n  credentials from the `FLYTE_AWS_ENDPOINT` / `FLYTE_AWS_ACCESS_KEY_ID` /\n  `FLYTE_AWS_SECRET_ACCESS_KEY` env vars (`flyte/storage/_config.py`); with none set\n  it falls back to the default AWS credential chain and probes the EC2 metadata\n  endpoint, so tasks fail with\n  `OSError: Generic S3 error: Error performing PUT http://169.254.169.254/latest/api/token`.\n  The chart's `storage.*` values do **not** propagate to task pods — inject the vars\n  via `plugins.k8s.default-env-vars`.\n- **Task I/O goes to a nonexistent bucket.** `runs.storagePrefix` defaults to\n  `s3://flyte-data`, so task input/output/`error.pb` writes fail with\n  `403 Forbidden AccessDenied`. It is **distinct from**\n  `metadataContainer`/`userDataContainer` (those only configure the control plane's\n  dataproxy) — point it at the real bucket.\n\nAdd this under `configuration:`. **The `default-env-vars` list replaces the chart\ndefault outright**, so the three `_U_*` control-plane vars must be repeated — dropping\nthem breaks task→control-plane callbacks:\n\n```yaml\n  inline:\n    runs:\n      storagePrefix: s3://<bucket>      # the SAME bucket as the storage block\n    plugins:\n      k8s:\n        default-env-vars:               # replaces the chart default — keep all three _U_* vars\n          - _U_EP_OVERRIDE: \"flyte-http.flyte:8090\"\n          - _U_INSECURE: \"true\"\n          - _U_USE_ACTIONS: \"1\"\n          - FLYTE_AWS_ACCESS_KEY_ID: \"<access-key-id>\"\n          - FLYTE_AWS_SECRET_ACCESS_KEY: \"<secret-access-key>\"\n          # Cloudflare R2 only — task pods must also be told the endpoint:\n          - FLYTE_AWS_ENDPOINT: \"https://<account-id>.r2.cloudflarestorage.com\"\n```\n\nFor **AWS S3**, omit `FLYTE_AWS_ENDPOINT` and add the standard\n`- AWS_REGION: \"<bucket-region>\"` instead (the SDK's object store reads the standard\nAWS env vars for anything the `FLYTE_AWS_*` overrides don't cover).\n\n## Step 4: Install Flyte\n\n```bash\nhelm repo add flyteorg https://flyteorg.github.io/flyte\nhelm repo update\nhelm install flyte flyteorg/flyte-binary -n flyte -f values-local.yaml\n\nkubectl -n flyte rollout status deploy/flyte\nkubectl -n flyte get pods\n```\n\nIf a pod is stuck in `Init`, the `wait-for-db` init container is blocking on\nPostgreSQL — the DB isn't up yet, or the host/credentials are wrong. Check\n`kubectl -n flyte logs <pod> -c wait-for-db`.\n\n## Step 5: Verify access\n\nMake the API reachable at `localhost:8090` on the machine where the SDK/CLI runs:\n\n```bash\nkubectl -n flyte port-forward service/flyte-http 8090:8090\n```\n\n**On a DigitalOcean droplet** the port-forward runs on the droplet, so tunnel it\nback to the user's own machine over SSH — this one command starts the port-forward\non the droplet *and* exposes it at `localhost:8090` locally:\n\n```bash\nssh -L 8090:localhost:8090 root@<droplet-ip> \\\n  kubectl -n flyte port-forward service/flyte-http 8090:8090\n```\n\n> [!NOTE] `helm upgrade` kills this port-forward\n> Every `helm upgrade` rolls the flyte pod, which drops the `flyte-http`\n> port-forward — the SDK then reports \"Flyte system is currently unavailable.\"\n> Restart the port-forward (and the SSH tunnel, on a droplet) after each upgrade.\n\nIn another terminal:\n\n```bash\ncurl -s -X POST \\\n  http://localhost:8090/flyteidl2.project.ProjectService/ListProjects \\\n  -H 'Content-Type: application/json' -d '{}'\n```\n\nA JSON response (not a connection error) confirms Flyte is up and talking to\nits database. The base deployment is done.\n\n**To submit runs from the SDK**, point it at the API forward. **Ask the user where\ntheir SDK config lives** — the SDK reads the project-local `.flyte/config.yaml` (the\nrun directory) before `~/.flyte/config.yaml` — **and whether to edit it for them or\njust give them the block to apply themselves.** Use this config:\n\n```yaml\nadmin:\n  endpoint: dns:///localhost:8090   # the port-forwarded API — 8090, NOT 8080\n  insecure: True                    # plain HTTP, no TLS\ntask:\n  org: local\n  domain: development\n  project: flytesnacks\n```\n\nThe code-bundle upload needs no second port-forward — the S3/R2 endpoint is\npublicly resolvable, so the SDK uploads straight to the bucket.\n\n**Two different ports are in play — don't conflate them.** The SDK talks to the\nAPI on **`:8090`** (this port-forward), while the browser console lives on\n**`:8080`** (Step 6). The run URL that `flyte run` prints\n(`http://localhost:8080/v2/...`) is a **console** link — it only works once Step 6\nis done; it is not the API endpoint, and pointing `admin.endpoint` at `:8080`\ndoes not work.\n\n## Step 6: Access the web console (no auth)\n\nThe base deployment leaves the console unreachable: port-forwarding\n`flyte-console` directly serves only the SPA, whose frontend calls the API at the\n**same origin** it was served from (`NEXT_PUBLIC_ADMIN_API_URL` is unset, so the\nAPI base URL defaults to `/`) — those calls 404 and run pages load blank. The fix\nis to put console + API behind **one origin** with Traefik.\n\nInstall Traefik (identical to step 1 of the auth section — if it's already\ninstalled, skip this command):\n\n```bash\nhelm repo add traefik https://traefik.github.io/charts\nhelm repo update\n\nhelm install traefik traefik/traefik -n traefik --create-namespace \\\n  --kube-context kind-flyte \\\n  --set \"service.type=NodePort\" \\\n  --set \"ports.web.nodePort=30080\" \\\n  --set \"ports.websecure.nodePort=30443\"\n```\n\nRoute the two path groups to one origin — `flyteidl2.*` (the Connect API, over\nh2c) to `flyte-http`, everything else to the console:\n\n```bash\nkubectl --context kind-flyte apply -f - <<'EOF'\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: flyte-api-noauth\n  namespace: flyte\nspec:\n  entryPoints: [web]\n  routes:\n    - kind: Rule\n      priority: 100\n      match: PathPrefix(`/flyteidl2.`)\n      services:\n        - name: flyte-http\n          port: 8090\n          scheme: h2c        # gRPC/Connect over cleartext HTTP/2\n---\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: flyte-console-noauth\n  namespace: flyte\nspec:\n  entryPoints: [web]\n  routes:\n    - kind: Rule\n      priority: 10\n      match: PathPrefix(`/`)\n      services:\n        - name: flyte-console\n          port: 80\nEOF\n```\n\nThen open the console:\n\n- **Local machine** — forward Traefik to **8080** (this makes the\n  `http://localhost:8080/v2/...` run URLs the SDK prints work as-is):\n  ```bash\n  kubectl -n traefik --context kind-flyte port-forward service/traefik 8080:80\n  ```\n  Open `http://localhost:8080/v2`. (Alternatively, the Step 1 host-port mapping\n  already exposes Traefik at `http://localhost/v2` with no port-forward — but the\n  SDK's printed run URLs still say `:8080`.)\n- **DigitalOcean droplet** — the Step 1 mapping binds host port 80 on the\n  droplet's public IP, so the console is directly at **`http://<droplet-ip>/v2`**\n  (the Step 0 firewall scopes it to the user's IP). Or tunnel it:\n  `ssh -N -L 8080:localhost:80 root@<droplet-ip>` → `http://localhost:8080/v2`.\n  Note the SDK still prints run URLs as `http://localhost:8080/...` — swap\n  `localhost:8080` for `<droplet-ip>` unless the tunnel is up.\n\nThese routes carry **no auth** — they're the evaluation-mode front door. If the\nuser later enables the auth section, **delete them first**\n(`kubectl -n flyte delete ingressroute flyte-api-noauth flyte-console-noauth`);\nthey match any host at low priority and would otherwise bypass the OIDC gate.\n\nNow **ask the user whether they want to add OIDC authentication.** The base\ndeployment has no auth — anyone with network access can reach the API. If they\nsay yes, do the \"Add OIDC authentication via an ingress controller\" section\nbelow. If no, stop here.\n\n## Optional extras\n\nOnly do these if the user asks.\n\n- **Load a local image into kind** (custom task/Flyte image, no registry):\n  ```bash\n  kind load docker-image <your-image>:<tag> --name flyte\n  ```\n  Reference that exact `<image>:<tag>` in task config; `IfNotPresent` pull\n  policy then uses the loaded image. On a **DigitalOcean droplet** the image must\n  be in the droplet's Docker daemon first — build it there, or ship it from the\n  user's machine with `docker save <image> | ssh root@<droplet-ip> docker load`.\n\n## Add OIDC authentication via an ingress controller\n\nDo this when the user opts in at the Step 6 prompt (or asks later). This adds\nOIDC single sign-on at the edge, the kind equivalent of gating the cloud\nconsole behind an ALB.\n\nThe pattern: run [Traefik](https://doc.traefik.io/traefik/) as the ingress\ncontroller and delegate auth to\n[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/). Traefik\nintercepts each request through a `ForwardAuth` middleware, asks oauth2-proxy\nwhether the caller is logged in, and redirects to the IdP if not. oauth2-proxy\nis the auth proxy at the edge.\n\n### First: choose the OIDC provider\n\noauth2-proxy needs an OIDC provider to validate against. **Ask the user which\nthey want** before installing anything:\n\n- **External IdP** (Okta, Google, Auth0, …) — for a setup close to production.\n  Requires a registered app with redirect URI `http://flyte.local/oauth2/callback`,\n  and its **client ID** and **client secret**. If the user picks this but\n  doesn't have those ready, stop — the rest won't work.\n- **Dex (local, in-cluster)** — an IdP stand-in for testing, no cloud account\n  or real users. If the user picks this, you'll deploy Dex via the\n  **`start-dex-local` skill** after Traefik is up (step 2 below).\n\nSteps 1 and 3–4 are the same either way; only step 2 (the provider) differs.\n\n### 1. Install Traefik\n\n**First confirm the cluster has the `hostPort: 80 → 30080` mapping from Step 1**\n(`docker ps --filter name=flyte-control-plane --format '{{.Ports}}'` should show\n`0.0.0.0:80->30080/tcp`). If it doesn't — e.g. the user reused a plain cluster —\n`http://flyte.local` can't reach Traefik, and the mapping can't be added in\nplace. Stop and have the user recreate the cluster with the auth-ready config in\nStep 1 (`kind delete cluster --name flyte`, then recreate). Warn that this wipes\nall data. If the user also wants SDK auth, the cluster needs `hostPort: 443 →\n30443` too (also from Step 1) — same recreate-if-missing rule.\n\nExpose both entrypoints: `web` (HTTP, for the browser) and `websecure` (HTTPS,\nfor the SDK — the SDK only authenticates over TLS):\n\n```bash\nhelm repo add traefik https://traefik.github.io/charts\nhelm repo update\n\nhelm install traefik traefik/traefik -n traefik --create-namespace \\\n  --kube-context kind-flyte \\\n  --set \"service.type=NodePort\" \\\n  --set \"ports.web.nodePort=30080\" \\\n  --set \"ports.websecure.nodePort=30443\"\n```\n\nInstalls the `Middleware` CRD, registers a `traefik` IngressClass, and serves a\ndefault self-signed cert on `websecure` — fine for the browser.\n\n**If Traefik is already installed from Step 6**, skip the install but **delete\nthe no-auth routes** — they match any host at low priority and would bypass the\nOIDC gate added below:\n\n```bash\nkubectl -n flyte --context kind-flyte delete ingressroute flyte-api-noauth flyte-console-noauth\n```\n\n#### Replace the default cert with one for `flyte.local` (only if SDK auth)\n\nSkip this if the user only needs the browser console. The SDK rejects Traefik's\ndefault cert for two reasons, hit in sequence if you only set `insecureSkipVerify`:\n\n- Its SAN is `*.traefik.default`, so the hostname check fails with\n  `certificate not valid for name \"flyte.local\"`. The SDK validates the SAN **even\n  with `insecureSkipVerify`** (that flag relaxes CA trust, not the hostname).\n- The SDK implements `insecureSkipVerify` by fetching the server's chain and\n  **pinning it as the CA**. A bare self-signed leaf then fails with\n  `CaUsedAsEndEntity` — rustls won't use a leaf as a CA.\n\nThe fix is a **two-tier chain**: a self-signed root CA signs a leaf carrying\n`SAN=flyte.local`. Traefik serves `leaf + CA`; the SDK pins the root as CA.\n\n```bash\n# 1. Root CA\nopenssl req -x509 -nodes -newkey rsa:2048 -days 3650 \\\n  -keyout ca.key -out ca.crt -subj \"/CN=flyte-local-ca\" \\\n  -addext \"basicConstraints=critical,CA:TRUE\" \\\n  -addext \"keyUsage=critical,keyCertSign,cRLSign\"\n# 2. Leaf key + CSR\nopenssl req -nodes -newkey rsa:2048 -keyout leaf.key -out leaf.csr -subj \"/CN=flyte.local\"\n# 3. CA signs the leaf (CA:FALSE, SAN=flyte.local, server auth)\nopenssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 3650 -out leaf.crt \\\n  -extfile <(printf \"subjectAltName=DNS:flyte.local\\nbasicConstraints=critical,CA:FALSE\\nkeyUsage=critical,digitalSignature,keyEncipherment\\nextendedKeyUsage=serverAuth\")\n# 4. Secret holds the full chain so Traefik serves both\ncat leaf.crt ca.crt > fullchain.crt\nkubectl --context kind-flyte -n traefik create secret tls flyte-local-tls \\\n  --cert=fullchain.crt --key=leaf.key\n```\n\nPoint Traefik's cluster-wide default cert at it with a `TLSStore` named `default`\n(the only name Traefik honours), then restart Traefik:\n\n```bash\nkubectl --context kind-flyte apply -f - <<'EOF'\napiVersion: traefik.io/v1alpha1\nkind: TLSStore\nmetadata:\n  name: default\n  namespace: traefik\nspec:\n  defaultCertificate:\n    secretName: flyte-local-tls\nEOF\nkubectl --context kind-flyte -n traefik rollout restart deploy/traefik\n```\n\nThe cert still chains to a self-signed root the SDK doesn't trust, so the SDK\nconfig in step 5 **still** sets `insecureSkipVerify`. To drop that entirely you'd\ninstall `ca.crt` into each client's trust store, or use a publicly-resolvable\ndomain + a publicly-trusted cert (Traefik ACME / Let's Encrypt) — impossible for a\npurely-local `flyte.local`.\n\n### 2. Set up the provider + oauth2-proxy\n\n**If the user chose Dex:** invoke the **`start-dex-local` skill** now (Traefik\nis up, which it requires). That skill deploys Dex, routes its issuer through\nTraefik, and installs oauth2-proxy already pointed at Dex\n(`oidc-issuer-url=http://flyte.local/dex`). When it finishes, skip to step 3 —\nthe middlewares. (If the user also wants SDK auth, the three SDK Bearer flags\nbelow must be added to that oauth2-proxy install too — `helm upgrade` it with\n`--reuse-values` and the three `--set extraArgs.*` lines.)\n\n**If the user chose an external IdP:** install oauth2-proxy yourself.\n`set-xauthrequest` emits the `X-Auth-Request-*` headers Traefik forwards\ndownstream (these feed Flyte's `executed_by` run attribution); `reverse-proxy`\ntrusts the forwarded host/proto from Traefik. The last three flags let the\n**SDK/CLI** authenticate too (not just the browser) — include them now if the\nuser wants SDK auth, so you don't have to upgrade later. Substitute the user's\nIdP values for the `<...>` placeholders.\n\n```bash\n# Cookie secret MUST decode to 16/24/32 bytes — head -c 32 trims the base64\n# string; a raw 44-char value fails with \"cookie_secret must be 16, 24, or 32 bytes\".\nCOOKIE_SECRET=$(openssl rand -base64 32 | head -c 32)\n\nhelm repo add oauth2-proxy https://oauth2-proxy.github.io/manifests\nhelm repo update\n\nhelm install oauth2-proxy oauth2-proxy/oauth2-proxy -n flyte \\\n  --kube-context kind-flyte \\\n  --set config.clientID='<oidc-client-id>' \\\n  --set config.clientSecret='<oidc-client-secret>' \\\n  --set config.cookieSecret=\"$COOKIE_SECRET\" \\\n  --set extraArgs.provider=oidc \\\n  --set extraArgs.oidc-issuer-url='https://<your-idp>/oauth2/default' \\\n  --set extraArgs.upstream='static://202' \\\n  --set extraArgs.reverse-proxy='true' \\\n  --set extraArgs.set-xauthrequest='true' \\\n  --set extraArgs.email-domain='*' \\\n  --set extraArgs.cookie-secure='false' \\    # local HTTP, not HTTPS\n  --set extraArgs.skip-jwt-bearer-tokens='true' \\      # accept SDK Bearer JWTs\n  --set extraArgs.oidc-extra-audience='<public-client-id>' \\  # SDK client's audience\n  --set extraArgs.bearer-token-login-fallback='false'  # invalid token → 403, not HTML\n```\n\nThe browser uses the session cookie; the SDK sends an `Authorization: Bearer`\nJWT. `skip-jwt-bearer-tokens` verifies that JWT against the IdP's JWKS and passes\nit through; `oidc-extra-audience` must be the **public client ID** the SDK uses\n(the `flyteClient.clientId` advertised in `authMetadata`) — its tokens carry that\naudience. The flag is **singular** (`oidc-extra-audience`); the plural form is not\na valid flag and crash-loops oauth2-proxy with `unknown flag`. Without these flags\nthe SDK is rejected and `flyte.run` fails the upload with `Unauthorized`.\n\n### 3. Create the ForwardAuth middlewares\n\nTwo Traefik `Middleware` objects: one sends each request to oauth2-proxy for a\nverdict and forwards the identity headers; the other catches the `401` an\nunauthenticated request gets and redirects to the sign-in page. Apply with\n`kubectl --context kind-flyte apply -f -`:\n\n```yaml\napiVersion: traefik.io/v1alpha1\nkind: Middleware\nmetadata:\n  name: oauth2-auth\n  namespace: flyte\nspec:\n  forwardAuth:\n    address: http://oauth2-proxy.flyte.svc.cluster.local/oauth2/auth\n    trustForwardHeader: true\n    authResponseHeaders:        # forwarded to Flyte; feed executed_by attribution\n      - X-Auth-Request-User\n      - X-Auth-Request-Email\n---\napiVersion: traefik.io/v1alpha1\nkind: Middleware\nmetadata:\n  name: oauth2-signin\n  namespace: flyte\nspec:\n  errors:\n    status:\n      - \"401\"\n    service:\n      name: oauth2-proxy\n      port: 80\n    query: \"/oauth2/sign_in?rd={url}\"\n```\n\n### 4. Enable the Flyte ingress with the middlewares\n\nReplace the `ingress.create: false` block in `values-local.yaml` with this. The\n`router.middlewares` annotation chains both middlewares onto every route\n(reference format `<namespace>-<name>@kubernetescrd`):\n\n```yaml\ningress:\n  create: true\n  host: flyte.local                 # add \"127.0.0.1 flyte.local\" to /etc/hosts\n  ingressClassName: traefik\n  httpAnnotations:\n    traefik.ingress.kubernetes.io/router.middlewares: flyte-oauth2-signin@kubernetescrd,flyte-oauth2-auth@kubernetescrd\n```\n\nAlso apply a route that sends `/oauth2` to oauth2-proxy itself so the sign-in\nredirect resolves:\n\n```yaml\napiVersion: networking.k8s.io/v1\nkind: Ingress\nmetadata:\n  name: oauth2-proxy\n  namespace: flyte\nspec:\n  ingressClassName: traefik\n  rules:\n  - host: flyte.local\n    http:\n      paths:\n      - path: /oauth2\n        pathType: Prefix\n        backend:\n          service:\n            name: oauth2-proxy\n            port:\n              number: 80\n```\n\nFor `executed_by` run attribution, add `identityHeaders` to `values-local.yaml` so\nFlyte reads the headers oauth2-proxy forwards (`X-Auth-Request-*`), not ALB's\n`X-Amzn-Oidc-*` defaults — otherwise `executed_by` is left unset:\n\n```yaml\nflyte-core-components:\n  runs:\n    identityHeaders:\n      claimsJwtHeader: \"\"\n      subjectHeader: X-Auth-Request-User\n      emailHeader: X-Auth-Request-Email\n```\n\nRe-render Flyte:\n\n```bash\nhelm upgrade flyte flyteorg/flyte-binary -n flyte --kube-context kind-flyte \\\n  -f values-local.yaml\n```\n\nThen add a hosts entry so the browser can resolve `flyte.local` to the local\nTraefik node port. Editing `/etc/hosts` needs sudo, so **have the user run it**\nrather than running it yourself. First check if it's already there:\n\n```bash\ngrep -q \"flyte.local\" /etc/hosts && echo \"present\" || echo \"absent\"\n```\n\n- **`present`** → continue.\n- **`absent`** → tell the user to run it (suggest `! echo \"127.0.0.1\n  flyte.local\" | sudo tee -a /etc/hosts` so it runs in this session), then **ask\n  whether they've added it or want to skip.** If added, re-run the `grep` to\n  confirm, then continue. If skip, stop here — the deployment is complete, but\n  browser login won't work until the entry exists. (Opening the console by raw\n  IP is not a substitute: Traefik has no route for that host, and the OIDC issuer\n  is `flyte.local`, so login fails on an issuer mismatch.)\n\n**On a DigitalOcean droplet**, Traefik's node ports are bound to the droplet's\n**public IP**, so point `flyte.local` there in the user's **own machine's**\n`/etc/hosts` (not the droplet's) — `echo \"<droplet-ip> flyte.local\" | sudo tee -a\n/etc/hosts`. Every other `flyte.local` reference (Dex issuer, redirect URIs, cert\nSAN, ingress host) stays the same; only this mapping differs. Alternatively, point\na real DNS A record at the droplet and substitute that hostname everywhere.\n\nOnce present, open `http://flyte.local/v2` — Traefik bounces you through the IdP\nand back into the console.\n\nThis gates the **browser** only.\n\n#### Split the API and discovery paths off the browser middleware (required for SDK auth)\n\nThe same `oauth2-signin` redirect on **every** path breaks the SDK, so do this before\nSDK auth. Two path groups need different handling (the cloud equivalent is the\nthree-ingress `ingress`/`apiJwtIngress`/`wellknownIngress` split):\n\n- **Auth-discovery** (`AuthMetadataService`, `IdentityService`) — the SDK reads these\n  *before* it has a token, so they must **bypass auth**. Gated, they return a\n  `text/plain` 401 that ConnectRPC reports as `UNAVAILABLE` (`flyte.run` fails with\n  \"Service is unavailable\"), and the SDK never starts login.\n- **The `flyteidl2.*` API** — needs `oauth2-auth` (Bearer validation) but **not**\n  `oauth2-signin`, so an unauthenticated call gets a clean gRPC 401 the SDK retries\n  after login, not sign-in HTML.\n\nTwo higher-priority `IngressRoute`s (Traefik matches highest `priority` first):\n\n```bash\nkubectl --context kind-flyte apply -f - <<'EOF'\n# Discovery — highest priority, NO middleware (= wellknownIngress).\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: flyte-auth-discovery\n  namespace: flyte\nspec:\n  entryPoints: [web, websecure]\n  routes:\n    - kind: Rule\n      priority: 300\n      match: Host(`flyte.local`) && (PathPrefix(`/flyteidl2.auth.AuthMetadataService`) || PathPrefix(`/flyteidl2.auth.IdentityService`))\n      services:\n        - name: flyte-http\n          port: 8090\n          scheme: h2c        # gRPC over cleartext HTTP/2\n---\n# API — oauth2-auth only, no oauth2-signin (= apiJwtIngress).\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: flyte-api-bearer\n  namespace: flyte\nspec:\n  entryPoints: [web, websecure]\n  routes:\n    - kind: Rule\n      priority: 100\n      match: Host(`flyte.local`) && PathPrefix(`/flyteidl2.`)\n      middlewares:\n        - name: oauth2-auth\n      services:\n        - name: flyte-http\n          port: 8090\n          scheme: h2c\nEOF\n```\n\nVerify discovery returns JSON, not oauth2-proxy's 401:\n```bash\ncurl -s -X POST --resolve flyte.local:443:127.0.0.1 -k \\\n  https://flyte.local/flyteidl2.auth.AuthMetadataService/GetPublicClientConfig \\\n  -H 'Content-Type: application/json' -d '{}' | head -c 120\n# → {\"clientId\":\"flytectl\", ...}   (JSON, not \"Unauthorized\")\n```\n\nThe `--resolve flyte.local:<port>:127.0.0.1` flags here (and in every other `curl`\nbelow) assume the command runs on the host running kind. **On a DigitalOcean\ndroplet** that means running them in the SSH session, where `127.0.0.1` works\nas-is; to run them from the user's own machine instead, substitute the droplet's\npublic IP for `127.0.0.1`.\n\n### 5. Let the SDK/CLI authenticate (only if the user wants to submit runs)\n\nThe browser flow works over plain HTTP, but **the SDK does not**: it attaches its\nauth interceptors only over **TLS**. With `insecure: True` it assumes \"plaintext ⇒\nno auth\" and sends no token, so `flyte.run` fails the upload with `Unauthorized`\nand no browser opens. Getting the SDK through auth needs the TLS pieces from above\n(websecure on `30443`, the `443` mapping, and the three oauth2-proxy Bearer flags)\nplus the SDK config below.\n\n**Point the SDK at HTTPS.** The SDK reads the **project-local** `.flyte/config.yaml`\n(the directory the run command is invoked from) *before* `~/.flyte/config.yaml`, so the\nright file isn't always the home one. **Ask the user which config file applies, and\nwhether to edit it for them or just hand them the block to apply themselves** — don't\nassume `~/.flyte/config.yaml`. Use this config:\n\n```yaml\nadmin:\n  endpoint: dns:///flyte.local        # must match SelectCluster's clusterEndpoint (no :443)\n  insecure: False                     # TLS — the SDK only authenticates over TLS\n  insecureSkipVerify: True            # accept the self-signed CA (camelCase! see below)\n  authType: Pkce\ntask:\n  org: local\n  domain: development\n  project: flytesnacks\n```\n\n**The key is camelCase `insecureSkipVerify`** — the SDK reads `admin.insecureSkipVerify`;\nsnake_case `insecure_skip_verify` is silently ignored, so the SDK keeps full verification\nand fails on the self-signed cert.\n\nThe `endpoint` must match what `SelectCluster` returns, or the SDK builds a\nseparate per-cluster session for the upload that may skip auth. Check it:\n```bash\ncurl -s -X POST --resolve flyte.local:443:127.0.0.1 -k \\\n  https://flyte.local/flyteidl2.cluster.ClusterService/SelectCluster \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"operation\":\"OPERATION_CREATE_UPLOAD_LOCATION\",\"project\":\"flytesnacks\",\"domain\":\"development\",\"org\":\"local\"}'\n# → {\"clusterEndpoint\":\"https://flyte.local\"}  ⇒  endpoint: dns:///flyte.local  (no :443)\n```\n\n**If the IdP runs in-cluster (Dex):** an external IdP needs nothing more, but Dex\nneeds two fixes so Flyte's `GetOAuth2Metadata` (which fetches the IdP's discovery\ndoc to tell the SDK where to log in) succeeds:\n\n- **DNS** — Flyte fetches `http://flyte.local/dex/...`, unresolvable in-cluster.\n  Add `flyte.local → Traefik ClusterIP` to the Flyte pod:\n  ```bash\n  TRAEFIK_IP=$(kubectl -n traefik --context kind-flyte get svc traefik -o jsonpath='{.spec.clusterIP}')\n  helm upgrade flyte flyteorg/flyte-binary -n flyte --kube-context kind-flyte -f values-local.yaml \\\n    --set \"deployment.extraPodSpec.hostAliases[0].ip=$TRAEFIK_IP\" \\\n    --set \"deployment.extraPodSpec.hostAliases[0].hostnames[0]=flyte.local\"\n  ```\n- **Discovery path** — Flyte fetches `/.well-known/oauth-authorization-server`\n  (RFC 8414), but Dex only serves `/.well-known/openid-configuration` (→ 404).\n  Same endpoints; rewrite at Traefik:\n  ```bash\n  kubectl --context kind-flyte apply -f - <<'EOF'\n  apiVersion: traefik.io/v1alpha1\n  kind: Middleware\n  metadata:\n    name: dex-wellknown-rewrite\n    namespace: flyte\n  spec:\n    replacePathRegex:\n      regex: ^/dex/\\.well-known/oauth-authorization-server$\n      replacement: /dex/.well-known/openid-configuration\n  ---\n  apiVersion: traefik.io/v1alpha1\n  kind: IngressRoute\n  metadata:\n    name: dex-oauth-metadata\n    namespace: flyte\n  spec:\n    entryPoints: [web, websecure]\n    routes:\n      - kind: Rule\n        priority: 200\n        match: Host(`flyte.local`) && Path(`/dex/.well-known/oauth-authorization-server`)\n        middlewares:\n          - name: dex-wellknown-rewrite\n        services:\n          - name: dex\n            port: 5556\n  EOF\n  ```\n\nVerify metadata resolves (should return JSON, not 404/timeout), then run an\nexample — `flyte.run` opens a browser to log in, then submits with the token:\n```bash\ncurl -s -X POST --resolve flyte.local:443:127.0.0.1 -k \\\n  https://flyte.local/flyteidl2.auth.AuthMetadataService/GetOAuth2Metadata \\\n  -H 'Content-Type: application/json' -d '{}' | head -c 200\n```\n\n**First clear any stale SDK token from a previous cluster.** The SDK caches OAuth\ntokens in the keyring (macOS Keychain), keyed by endpoint host — `kind delete\ncluster` doesn't wipe them. Dex's `storage: memory` mints new signing keys on every\nrestart, so an old token fails signature check with `403 Forbidden` on\n`SelectCluster` and **no browser opens**. Clear it after any cluster/Dex recreate:\n\n```bash\n# macOS; \"not found\" is fine. Linux: keyring del flyte.local access_token / refresh_token\nfor k in access_token refresh_token; do security delete-generic-password -s flyte.local -a \"$k\" 2>/dev/null; done\n```\n\n**SDK-auth troubleshooting:**\n- Upload `Unauthorized`, **no browser** → SDK on plain HTTP. Use `insecure: False` + `https://flyte.local`.\n- `Connection refused` to `https://flyte.local` → no TLS listener (websecure not exposed, or no `30443 → 443` mapping).\n- 401 *after* a successful browser login → oauth2-proxy rejects the Bearer token; confirm `skip-jwt-bearer-tokens` + `oidc-extra-audience=<client-id>`; check its logs for `audience ... does not match`.\n- `403 Forbidden` on `SelectCluster`, **no browser** (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.\n- `GetOAuth2Metadata` 404 `oauth-authorization-server` → in-cluster IdP: well-known rewrite missing.\n- `GetOAuth2Metadata` times out → in-cluster IdP: `hostAliases` missing on the Flyte pod.\n\n## Enable app serving (optional — Knative + Kourier)\n\nFlyte can host long-running **apps** (web services, dashboards, model servers —\ndeployed via the SDK), each published at `{name}-{project}-{domain}.<base-domain>`.\nIt's **off by default**: the binary always exposes `AppService`, but with no\ncontroller behind it the console's Apps tab and any `flyteidl2.app.AppService/List`\ncall return `{\"code\":\"unimplemented\",\"message\":\"404 Not Found\"}` until enabled.\nApps run as **Knative Services**, so Knative Serving + a Knative networking layer\n(Kourier) must be installed first. Skip this section unless the user wants apps.\nOfficial doc: https://www.union.ai/docs/v2/flyte/oss-deployment/app-serving/.\n\nOn kind there's no cloud load balancer and no real DNS, so this recipe uses\n**[sslip.io](https://sslip.io) wildcard DNS** (any `X.127.0.0.1.sslip.io` resolves\nto `127.0.0.1`; any `X.<droplet-ip>.sslip.io` to the droplet — `/etc/hosts` can't\ndo wildcards, and every app gets its own hostname) and routes app traffic through\n**Traefik** on the existing host-port-80 mapping. It requires Traefik from Step 6\n(or the auth section).\n\n**1. Install Knative Serving + Kourier.** Pick a Knative release that supports the\ncluster's k8s version (Knative supports only the most recent k8s minors — the\npinned version below may need bumping), and use the **same version** for serving\nand net-kourier:\n\n```bash\nKV=knative-v1.22.1   # must support your k8s version; serving + net-kourier must match\nkubectl --context kind-flyte apply -f https://github.com/knative/serving/releases/download/$KV/serving-crds.yaml\nkubectl --context kind-flyte apply -f https://github.com/knative/serving/releases/download/$KV/serving-core.yaml\nkubectl --context kind-flyte apply -f https://github.com/knative-extensions/net-kourier/releases/download/$KV/kourier.yaml\nkubectl --context kind-flyte patch configmap/config-network -n knative-serving --type merge \\\n  -p '{\"data\":{\"ingress-class\":\"kourier.ingress.networking.knative.dev\"}}'\nkubectl --context kind-flyte wait --for=condition=Available deploy --all -n knative-serving --timeout=180s\nkubectl --context kind-flyte wait --for=condition=Available deploy --all -n kourier-system --timeout=180s\n```\n\nIf `kubectl apply` rejects the manifests, the Knative release is newer than the\ncluster's k8s version supports — install an older one (serving + net-kourier\nmatched).\n\n**2. Configure the apps domain.** Base domain = `127.0.0.1.sslip.io` locally, or\n`<droplet-ip>.sslip.io` on a droplet. Drop the namespace from Knative's hostname\ntemplate so each app is a **single label** under the base domain (the default\n`{{.Name}}.{{.Namespace}}.{{.Domain}}` is two labels):\n\n```bash\nBASE=127.0.0.1.sslip.io   # droplet: <droplet-ip>.sslip.io\nkubectl --context kind-flyte patch configmap/config-domain -n knative-serving --type merge \\\n  -p \"{\\\"data\\\":{\\\"$BASE\\\":\\\"\\\"}}\"\nkubectl --context kind-flyte patch configmap/config-network -n knative-serving --type merge \\\n  -p '{\"data\":{\"domain-template\":\"{{.Name}}.{{.Domain}}\"}}'\n```\n\n**3. Route app hostnames through Traefik.** Kourier's `kourier` Service is\n`LoadBalancer` type, which stays `<pending>` forever on kind — switch it to\n`ClusterIP` and front it with an IngressRoute that matches any sslip.io app host.\nThe higher priority (150) wins over the Step 6 no-auth routes for app hosts;\n`localhost`/`flyte.local` traffic is untouched:\n\n```bash\nkubectl --context kind-flyte patch svc kourier -n kourier-system --type merge -p '{\"spec\":{\"type\":\"ClusterIP\"}}'\nkubectl --context kind-flyte apply -f - <<EOF\napiVersion: traefik.io/v1alpha1\nkind: IngressRoute\nmetadata:\n  name: kourier-apps\n  namespace: kourier-system\nspec:\n  entryPoints: [web]\n  routes:\n    - kind: Rule\n      priority: 150\n      match: HostRegexp(\\`^.+\\.${BASE//./\\\\.}$\\`)\n      services:\n        - name: kourier\n          port: 80\nEOF\n```\n\n**4. Enable the app controller in Flyte.** Add to `values-local.yaml` under the\nexisting `configuration.inline` block — `baseDomain` MUST equal the\n`config-domain` from step 2:\n\n```yaml\n    internalApps:\n      enabled: true\n      baseDomain: 127.0.0.1.sslip.io   # droplet: <droplet-ip>.sslip.io\n      scheme: http                     # plain HTTP through Traefik (evaluation)\n      ingressAppsPort: 0               # apps ride host port 80; omit the port\n```\n\n```bash\nhelm upgrade flyte flyteorg/flyte-binary -n flyte --kube-context kind-flyte -f values-local.yaml\nkubectl -n flyte --context kind-flyte rollout status deploy/flyte\n```\n\nThe chart auto-grants the `serving.knative.dev` RBAC when `internalApps.enabled`.\nThe upgrade rolls the flyte pod, so **restart the `flyte-http` port-forward**\n(Step 5) afterward.\n\n**5. Verify.**\n\n```bash\nkubectl --context kind-flyte auth can-i create services.serving.knative.dev \\\n  --as=system:serviceaccount:flyte:flyte -n flyte          # => yes\n# AppService now answers 200 + {} (NOT 404/unimplemented) — needs the Step 5 port-forward:\ncurl -s -o /dev/null -w '%{http_code}\\n' -X POST \\\n  http://localhost:8090/flyteidl2.app.AppService/List \\\n  -H 'Content-Type: application/json' -d '{}'              # => 200\n```\n\nThe console's Apps tab now loads. Deploy an app with the SDK and open\n`http://<name>-<project>-<domain>.<base-domain>/` (sslip.io needs internet DNS;\non a droplet the Step 0 firewall scopes port 80 to the user's IP).\n\n**Gotchas:** (a) Knative version too new for the k8s version → manifests rejected\non apply. (b) two-label app hostnames → confirm the single-label\n`domain-template`. (c) `baseDomain` ≠ `config-domain` → the URLs Flyte advertises\ndon't match what Knative serves. (d) `List` still 404s after enabling → the\nbinary didn't roll; `kubectl -n flyte rollout restart deploy/flyte`. (e) apps are\n**unauthenticated** — anyone who can reach port 80 can open them (locally that's\njust the machine; on a droplet, whatever the firewall admits).\n\n## Tear down\n\n```bash\nkind delete cluster --name flyte\n```\n\nDeletes the cluster and Flyte. **On a DigitalOcean droplet**, also destroy the\ndroplet so it stops billing:\n\n```bash\ndoctl compute droplet delete flyte-kind\n```\n\nThe hosted PostgreSQL and S3/R2 bucket are untouched — clean those up in their own\nconsoles.\n"
}

SHA-256 of public snapshot: fb1fa774599089a9594c49ae4808baedea055d5559530a1a1f7aabb2817380aa