How-to
Running the Fendlemark app on Kubernetes
The Fendlemark app is one binary: the control plane and the Fendlemark console it serves, one console for your whole ML platform. On a laptop it listens on 127.0.0.1:7411, and nothing else can reach it. To share it with a team, run it on Kubernetes next to the tools it reads, behind the single sign-on the team already uses. This guide does that without changing the listen address: oauth2-proxy runs in the same pod and signs people in, and every adapter gets credentials that can only read.
By the end, the console will be at https://platform.example.internal, reachable only through oauth2-proxy, with all four adapters connected. The examples assume an MLflow tracking server at http://mlflow.mlflow.svc:5000, a Metaflow metadata service, an Argo Server in the argo namespace that runs Workflows in ml-training and ml-batch, and Kubeflow Pipelines with two profiles, team-ml and team-risk. Your identity provider can be anything that speaks OpenID Connect. Substitute your own names throughout.
1. Keep the app on loopback, with the proxy beside it
server.listen defaults to 127.0.0.1:7411, and the configuration reference advises keeping it there, behind an authenticating reverse proxy, rather than widening it. In a Kubernetes pod the advice is easy to follow, because the containers in a pod share one network namespace. A proxy in the same pod reaches the app on loopback, and no other pod can reach the app directly.
browser ──HTTPS──▶ Ingress ──▶ Service :80 ──▶ pod
├─ oauth2-proxy 0.0.0.0:4180 signs people in
└─ fendlemark 127.0.0.1:7411 the console and APIoauth2-proxy sends each visitor to your identity provider, checks the result, and passes signed-in requests on to http://127.0.0.1:7411. fendlemark doctor will still report DOC-201, because auth.mode is none and "anyone who can reach server.listen can use the console". In this pod that is intended: the only thing that can reach server.listen is the proxy. The one exception is kubectl port-forward, which reaches loopback inside the pod, so grant pods/portforward in the fendlemark namespace as carefully as access to the console itself.
2. Create the credentials
Every Fendlemark adapter is read-only, and the adapters reference lists the least permission each one needs. Create a namespace and a service account for the app, and grant that account no more than that.
$ kubectl create namespace fendlemark
$ kubectl -n fendlemark create serviceaccount fendlemarkMLflow
The mlflow adapter reads the same variables as the MLflow client. On a tracking server that uses MLflow's basic authentication, make a user with READ on the experiments and registered models you want shown, and store its credentials under keys named exactly like the variables, so the Deployment can import them with envFrom:
$ kubectl -n fendlemark create secret generic fendlemark-mlflow \
--from-literal=MLFLOW_TRACKING_USERNAME=fendlemark \
--from-literal=MLFLOW_TRACKING_PASSWORD="$MLFLOW_PASSWORD"Per-experiment permissions narrow anything only if the server's default_permission is NO_PERMISSIONS; its default is READ. For a tracking server that takes bearer tokens, store MLFLOW_TRACKING_TOKEN instead.
Metaflow
The Metaflow metadata service has no authentication of its own, so the metaflow adapter needs only network access to it. If you put a proxy in front of the service, give the app a read-only identity there.
Argo Workflows
The argo adapter calls Argo Server with a bearer token. With Argo Server in client mode, its default, each request runs with the token's own permissions, so the app sees exactly what this Role allows: get, list, and watch on Workflows, WorkflowTemplates, and CronWorkflows.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: fendlemark-argo-read
namespace: ml-training # one Role and RoleBinding per namespace the adapter reads
rules:
- apiGroups: ["argoproj.io"]
resources: ["workflows", "workflowtemplates", "cronworkflows"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: fendlemark-argo-read
namespace: ml-training
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: fendlemark-argo-read}
subjects:
- {kind: ServiceAccount, name: fendlemark, namespace: fendlemark}
---
apiVersion: v1
kind: Secret
metadata:
name: fendlemark-argo
namespace: fendlemark
annotations:
kubernetes.io/service-account.name: fendlemark
type: kubernetes.io/service-account-tokenKubernetes fills the Secret's token key with a non-expiring token for the fendlemark service account. Since Kubernetes 1.24 it creates such a token only when a Secret like this one asks for it.
Create the Role and RoleBinding in every namespace you will list under adapters.argo.namespaces in step 3, including your Kubeflow profiles. Kubeflow Pipelines runs each pipeline as an Argo Workflow in the profile's namespace, and the argo adapter can show only what Argo Server lets it list. Kubeflow does not install Argo Server, only the workflow controller, so this assumes you have installed one separately and that it can see those namespaces. By default Argo Server serves a self-signed certificate, which the adapter rejects; settle that before step 3, as the first item under Things that will happen eventually describes.
Kubeflow Pipelines
In multi-user mode, the Kubeflow Pipelines API server accepts a service account token whose audience includes pipelines.kubeflow.org, and authorises it with Kubernetes RBAC in each profile. Define read access to the Pipelines resources once, as a ClusterRole, and bind it with a RoleBinding in each profile so that it grants nothing outside them:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: fendlemark-kfp-read
rules:
- apiGroups: ["pipelines.kubeflow.org"]
resources: ["runs", "pipelines", "experiments", "jobs"]
verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: fendlemark-kfp-read
namespace: team-ml # one RoleBinding per profile
roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: fendlemark-kfp-read}
subjects:
- {kind: ServiceAccount, name: fendlemark, namespace: fendlemark}This is narrower than Kubeflow's own kubeflow-pipelines-view role, which also lets its holder read run artifacts and logs.
Then issue a token with that audience and store it:
$ kubectl -n fendlemark create secret generic fendlemark-kubeflow \
--from-literal=KUBEFLOW_TOKEN="$(kubectl -n fendlemark create token fendlemark \
--audience pipelines.kubeflow.org --duration 168h)" \
--dry-run=client -o yaml | kubectl apply -f -Unlike the Argo token, this one expires, and the kubeflow adapter reads KUBEFLOW_TOKEN once, when serve starts. Rerun the command above well within the token's 168 hours, followed by kubectl -n fendlemark rollout restart deployment/fendlemark. The API server can cap how long a requested token lasts (--service-account-max-token-expiration), so check the expiry of the token you received rather than the duration you asked for.
A full Kubeflow installation also has a NetworkPolicy, ml-pipeline in the kubeflow namespace, that admits traffic to the Pipelines API only from profile namespaces and istio-system. Requests from the app's namespace are dropped, and since the adapter makes no request when it connects, the log still says connected. Add a policy that admits the app's namespace:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ml-pipeline-from-fendlemark
namespace: kubeflow
spec:
podSelector:
matchLabels:
app: ml-pipeline
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: fendlemark
ports:
- port: 8888The kubeflow adapter's katib and notebooks options default to true. Turn them off, as the configuration in step 3 does, unless you want Katib experiments and Notebook servers in the console. Both are read through the Kubernetes API rather than the Pipelines API, so they need Kubernetes credentials found the standard KUBECONFIG way, which this Deployment does not mount, plus get, list, and watch on experiments.kubeflow.org, trials.kubeflow.org, and notebooks.kubeflow.org. The adapters reference has the details.
Single sign-on
Register an OpenID Connect client for the console with the redirect URI https://platform.example.internal/oauth2/callback. Then store the client id, the client secret, and a random cookie secret for oauth2-proxy, which reads each of its flags from an environment variable named OAUTH2_PROXY_ followed by the flag's name in capitals, with underscores for hyphens:
$ kubectl -n fendlemark create secret generic oauth2-proxy \
--from-literal=OAUTH2_PROXY_CLIENT_ID=fendlemark-console \
--from-literal=OAUTH2_PROXY_CLIENT_SECRET="$OIDC_CLIENT_SECRET" \
--from-literal=OAUTH2_PROXY_COOKIE_SECRET="$(openssl rand -base64 32 | tr -- '+/' '-_')"3. Write the configuration
The app reads one file. Keep it in a ConfigMap; the secrets stay in the environment, under names the adapters already read.
apiVersion: v1
kind: ConfigMap
metadata:
name: fendlemark-config
namespace: fendlemark
data:
fendlemark.yaml: |
server:
base_url: "https://platform.example.internal"
data_dir: "/var/lib/fendlemark"
console:
title: "ML Platform"
default_view: runs
adapters:
mlflow:
enabled: true
tracking_uri: "http://mlflow.mlflow.svc:5000"
metaflow:
enabled: true
metadata_url: "http://metaflow-metadata.metaflow.svc:8080"
ui_url: "https://metaflow-ui.example.internal"
argo:
enabled: true
server: "https://argo-server.argo.svc:2746"
namespaces: ["ml-training", "ml-batch", "team-ml", "team-risk"]
kubeflow:
enabled: true
pipelines_url: "http://ml-pipeline.kubeflow.svc:8888"
profiles: ["team-ml", "team-risk"]
dashboard_url: "https://kubeflow.example.internal"
katib: false
notebooks: falseThree things are deliberate:
- There is no
server.listen. The default, loopback on port 7411, is what the proxy expects. server.base_urlis the public address. The console builds its own links from it, which would otherwise point at the loopback address.- The profiles are listed. In multi-user mode, the Pipelines API rejects a run listing that names no namespace, so
profilesmust say which ones to read.
Unknown keys are errors, not warnings: a misspelt key under server, console, or lattice makes serve exit with code 1 at start-up instead of being ignored. Inside an adapter's block, a missing required option or a value of the wrong type stops it the same way, but a misspelt option name is not caught: katlb: false leaves Katib on. Check the effective configuration that fendlemark doctor prints.
4. Deploy the app and the proxy
One Deployment runs both containers. The app takes its configuration from the ConfigMap and its credentials from the Secrets in step 2; the proxy takes the OpenID Connect client.
apiVersion: apps/v1
kind: Deployment
metadata:
name: fendlemark
namespace: fendlemark
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: fendlemark
template:
metadata:
labels:
app.kubernetes.io/name: fendlemark
spec:
serviceAccountName: fendlemark
automountServiceAccountToken: false # every credential arrives through the environment
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: fendlemark
image: ghcr.io/tophergopher/fendlemark:0.9.12
args: ["serve", "--config", "/etc/fendlemark/fendlemark.yaml"]
envFrom:
- secretRef:
name: fendlemark-mlflow
- secretRef:
name: fendlemark-kubeflow
env:
- name: ARGO_TOKEN
valueFrom:
secretKeyRef:
name: fendlemark-argo
key: token
readinessProbe: # through the proxy, which passes this path on
httpGet:
path: /readyz
port: 4180
periodSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: config
mountPath: /etc/fendlemark
readOnly: true
- name: data
mountPath: /var/lib/fendlemark
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
args:
- --provider=oidc
- --oidc-issuer-url=https://sso.example.internal/realms/platform
- --redirect-url=https://platform.example.internal/oauth2/callback
- --email-domain=example.com
- --upstream=http://127.0.0.1:7411/
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --trusted-proxy-ip=10.244.0.0/16 # your cluster's pod CIDR; step 5 admits only the ingress controller
- --skip-provider-button=true
- "--skip-auth-route=GET=^/(healthz|readyz)$"
envFrom:
- secretRef:
name: oauth2-proxy
ports:
- name: http
containerPort: 4180
livenessProbe:
httpGet:
path: /ping
port: http
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumes:
- name: config
configMap:
name: fendlemark-config
- name: data
emptyDir: {}The parts that are not boilerplate:
- The image is the binary and nothing else.
ghcr.io/tophergopher/fendlemarkis builtFROM scratchforlinux/amd64,linux/arm64, andlinux/arm/v7, and runs as user 65532. It has no shell and writes only toserver.data_dir, soreadOnlyRootFilesystemdoes not get in its way. The same image is on Docker Hub astophergopher/fendlemark. Pin a version, as here, rather thanlatest, so that a restart to pick up a new Kubeflow token does not also upgrade the console. - The data directory is an
emptyDir. Withauth.mode: none, the only state inserver.data_diris console preferences, which reset when the pod is replaced. Respiratory Identity would keep its enrolments there too. - Readiness goes through the proxy. The kubelet probes the pod's IP address, not its loopback, so it cannot reach port 7411 directly. The
--skip-auth-routeflag letsGET /healthzandGET /readyzthrough to the app without signing in.servedoes not listen at all until its initial reconciliation has finished, so until then the proxy answers502, the probe fails, and the pod receives no traffic. On a large platform, nobody sees a half-built console. - The app has no liveness probe.
/healthzonly says the process is up, and if the process exits, the kubelet restarts the container anyway. A liveness probe routed through the proxy would restart the app whenever the proxy misbehaved, which treats the wrong patient. --reverse-proxytrusts forwarded headers. With it, oauth2-proxy believes theX-Forwarded-*headers it is sent.--trusted-proxy-iplimits that trust to pod addresses, and the NetworkPolicy in step 5 narrows those to the ingress controller's pods; if your controller uses host networking, list the nodes' addresses instead. Without the flag, oauth2-proxy warns at start-up and trusts every source.- One replica. Two replicas would work: the lattice is deterministic, so once both have read the same facets they show the same entities with the same ids, and oauth2-proxy keeps its sessions in a cookie, so either can serve a signed-in person. Each counts its own lattice generations, though, so the number in the footer differs between them, and each reads every upstream independently, which doubles the load on MLflow, the Metaflow metadata service, Argo Server, and the Pipelines API. Run one unless the console's availability matters more than that load.
5. Expose the proxy through an Ingress
The Service points at the proxy's port, never at 7411, and a NetworkPolicy keeps everything but the ingress controller away from it:
apiVersion: v1
kind: Service
metadata:
name: fendlemark
namespace: fendlemark
spec:
selector:
app.kubernetes.io/name: fendlemark
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: fendlemark
namespace: fendlemark
spec:
ingressClassName: internal # your ingress controller's class
tls:
- hosts: ["platform.example.internal"]
secretName: platform-tls
rules:
- host: platform.example.internal
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: fendlemark
port:
name: http
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: fendlemark-ingress
namespace: fendlemark
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: fendlemark
policyTypes: ["Ingress"]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress # your ingress controller's namespace
ports:
- port: 4180With the NetworkPolicy, other pods in the cluster cannot reach the proxy except through the Ingress and its TLS. Use your own ingress controller's class and namespace; Kubernetes labels every namespace with kubernetes.io/metadata.name. A NetworkPolicy takes effect only if your cluster's network plugin enforces it.
6. Check it
Wait for the rollout, then check that each adapter connected and the first reconciliation finished:
$ kubectl -n fendlemark rollout status deployment/fendlemark
deployment "fendlemark" successfully rolled out
$ kubectl -n fendlemark logs deployment/fendlemark -c fendlemark | grep -E 'adapter|lattice|reconciliation'
time=2026-10-29T10:02:11.204Z level=INFO msg="fendlemark starting" version=0.9.12 adapters=argo,kubeflow,metaflow,mlflow
time=2026-10-29T10:02:11.204Z level=INFO msg="adapter connected" adapter=argo took=0s
time=2026-10-29T10:02:11.204Z level=INFO msg="adapter connected" adapter=kubeflow took=0s
time=2026-10-29T10:02:11.204Z level=INFO msg="adapter connected" adapter=metaflow took=0s
time=2026-10-29T10:02:11.204Z level=INFO msg="adapter connected" adapter=mlflow took=0s
time=2026-10-29T10:02:14.117Z level=INFO msg="lattice reconciled" entities=6112 facets=7986 generation=1 took=97ms
time=2026-10-29T10:02:14.117Z level=INFO msg="adapter watching" adapter=argo
time=2026-10-29T10:02:14.117Z level=INFO msg="adapter watching" adapter=kubeflow"Connected" means only that an adapter read its configuration; it makes no request until the first reconciliation. An adapter that cannot list, for a missing permission, a rejected token, or a certificate it does not trust, is named in a level=WARN line, msg="initial reconciliation had errors", which the grep above also shows.
A rollout that never finishes is almost always a pod that never becomes ready. To see what the probe sees, forward the proxy's port and ask:
$ kubectl -n fendlemark port-forward deployment/fendlemark 4180:4180 &
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:4180/readyz
502A 502 means the app is not listening yet: either the initial reconciliation is still running, or the container has exited, and its logs will say which. A 200 means the pod is ready.
Then open https://platform.example.internal and sign in. The Adapters view shows each adapter's health, latency, facet counts, and effective configuration, from the same checks that fendlemark adapters check and GET /api/v1/adapters run.
Things that will happen eventually
- Argo Server's certificate is self-signed. Argo Server encrypts by default with a self-signed certificate, and the argo adapter always verifies certificates, so a default installation leaves the adapter
unreachablewith a certificate error. Either give Argo Server a certificate from a CA you control and pointSSL_CERT_FILEat a bundle mounted in the pod, or, if the traffic never leaves the cluster, run Argo Server with--secure=falseand setserverto itshttp://address. - A CA bundle replaces the built-in roots. The image has no certificate store, and the binary uses the root certificates it carries only when it finds no others. A bundle in
SSL_CERT_FILEtherefore replaces them, so include public CAs in it if anything the app talks to needs one. - "Open in MLflow" and "Open in Argo" links point at the cluster. The console builds them from
tracking_uriandserver, so in-cluster addresses like the ones above do not open in a browser. Use addresses that resolve both in the cluster and on people's desks. The Kubeflow and Metaflow links come fromdashboard_urlandui_url, which are already public addresses. - The Kubeflow token expires quietly. The kubeflow adapter's watch skips listings that fail, and the Pipelines API's health check needs no token, so the adapter stays
healthywhile Kubeflow run states stop changing. A restart does not show it either: the adapter's health still comes from that check, so it still reportshealthy, and only the start-up warning that the initial reconciliation had errors names theHTTP 401. Reissue the token before it expires, not after; a token issued without--audience pipelines.kubeflow.orgfails the same way. - Somebody widens the listen address. Setting
server.listen(or--listen) to:7411lets anything that can reach the pod's IP address skip the proxy, and the app reportsDOC-121for exactly that reason. - Metrics are on loopback too.
/metricsanswers on the same address as the console, so Prometheus cannot scrape it in this layout. Adding it to--skip-auth-routemakes it scrapable, and also publishes it, unauthenticated, through the Ingress; Prometheus also needs a rule in step 5's NetworkPolicy. Do that only if the metrics may be read by anyone who can reach the console's address. - Signing in fails at the callback with a
502. The session cookie oauth2-proxy sets after an OpenID Connect sign-in can be larger than an ingress controller's buffer for response headers. Raise that buffer; on controllers built on nginx it isproxy-buffer-size, and oauth2-proxy's documentation suggests 16k or more. - The redirect URL must match exactly. If signing in loops, or your identity provider reports an invalid redirect, compare
--redirect-urlwith the URI registered for the client, character by character, including the scheme.
The console's own sign-in
Fendlemark does not integrate with identity providers. Its own sign-in, Respiratory Identity, is off by default: it needs a webcam at every desk and an enrolment for every person, three breaths each. Behind oauth2-proxy, the console admits whoever your identity provider admits, keeps them signed in for as long as the proxy's cookie lasts (--cookie-expire, a week by default), and asks nothing of anyone's lungs. If you enable Respiratory Identity anyway, mount a PersistentVolumeClaim at server.data_dir, where the enrolments are kept.
Further reading
- Getting started: the container image, for running the same image on one machine
- Configuration: server, for
server.listenandDOC-121, and the rest offendlemark.yaml - Adapters, for what each adapter reads and the least permission it needs
- CLI reference:
fendlemark doctor, forDOC-201 - Tracking Kubeflow Pipelines runs in MLflow and Running MLflow and Argo Workflows together, for what to tag so that the console has something to correlate
- oauth2-proxy: configuration overview, Argo Workflows: TLS, and Kubernetes: pod networking