The manual
The README automates this setup with scripts. This document performs the same setup by hand, explaining every configuration file and command involved in giving a non-Kubernetes workload a SPIFFE identity and joining it to a Linkerd mesh.
The worked example is RetailCloud: a point-of-sale service (store-pos) runs on
a machine outside Kubernetes and sends inventory and sales data to a cloud
application (retail-cloud) running in the cluster. The cloud service accepts the
data only from the store’s verified SPIFFE identity. The sections below build this
configuration incrementally.
This is a teaching demo, not a production blueprint. It takes deliberate shortcuts — for example, the dashboard app holds permission to change its own authorization policy — to keep the mechanics legible in one sitting. Do not run this configuration in a real environment. Production notes lists every shortcut and what to do instead.
Made with Claude Code. This demo and its documentation were built with Anthropic’s agentic coding tool.
Convention: commands prefixed
[cluster]run on the Kubernetes host;[store]run on the external machine. The SPIFFE trust domain throughout isroot.linkerd.cluster.local— a name we choose in Part 3’s SPIRE config, matching Linkerd’s default trust anchor name. (Linkerd’s own trust-domain setting is a separate thing and stays at its default,cluster.local. Both naming schemes appear below; both work because they share one root.)
The mental model
Section titled “The mental model”Four things have to line up:
- A single root of trust. Linkerd already issues every pod a certificate from a root CA (the trust anchor). We will make the external workload’s certificates chain to that same root, so both sides validate each other. The result is one trust domain spanning two machines.
- A data-plane proxy on the external machine. A standalone
linkerd2-proxyruns next to the workload, gets its identity from SPIRE (item 3), and does mTLS on the workload’s behalf — exactly like an injected sidecar does in the cluster. - An identity source on the external machine. In the cluster, the control-plane
component
linkerd-identityissues certificates, trusting a pod’s Kubernetes ServiceAccount. Off-cluster there is no Kubernetes, so SPIRE plays that role: it attests a local workload — verifies its OS-level identity (the user it runs as and the binary it is) rather than trusting a claim it makes — and issues it a short-lived SVID. The workload it attests here is the standalone proxy (item 2), which carries the identity on the app’s behalf — not the app itself (which never talks to SPIRE), and not the SPIRE agent (a separate party: attested as a node via its join token, and the one doing the attesting). (See Concepts → Attestation.) - The cluster’s awareness of the workload. An
ExternalWorkloadresource tells Linkerd this off-cluster process exists, so discovery and inbound policy treat it like any other mesh endpoint. It records the workload’s identity; it does not confer it — that comes from item 3.
A fifth thing is a prerequisite, not part of SPIFFE: the two machines need plain IP reachability (the proxy dials cluster pod IPs and resolves cluster DNS). SPIFFE gives you identity; it does not give you connectivity.
Prerequisites
Section titled “Prerequisites”- Two Linux hosts that can reach each other over IP. (On macOS, run each in a
Linux VM — note that default VM networking often isolates guests from one another;
you may need a shared VM-to-VM network, e.g. Lima’s
user-v2, or the Tailscale recipe. This reachability is a precondition, covered in Part 2.) - On the cluster host: a Kubernetes cluster (k3s is fine) and the
linkerdCLI (edge channel — mesh expansion needs 2.15+), plusstepto make certificates. - On the store host: the SPIRE agent binary (
spire-agent) — the server runs in the cluster (Part 3), so the store never needsspire-server— a container runtime (Docker/Podman) to run the app, andiptables. - Pick one Linkerd edge version and use it everywhere — the standalone proxy binary
must match the control plane. We’ll call it
$LINKERD_VERSION(e.g.edge-26.7.2).
Part 1 — One root of trust (cluster)
Section titled “Part 1 — One root of trust (cluster)”Linkerd’s identity system has two certificates: a long-lived trust anchor (the
root CA) and a shorter-lived issuer (an intermediate) that actually signs proxy
certs. Normally linkerd install generates both for you and you never see the root
key. We generate the pair ourselves so the in-cluster SPIRE server (Part 3) can use the
root as its UpstreamAuthority — the root key stays in the cluster the whole time.
Run these on the cluster host, in a working directory you then stay in (e.g.
mkdir ~/linkerd-certs && cd ~/linkerd-certs). step is offline — it only writes the four
cert files locally and never touches the cluster. Several later [cluster] steps read them
back by relative name — the issuer command below, linkerd install, the SPIRE Secret
(Part 3a), and the copy to the store (Part 3b) — so run all of those from this same
directory.
[cluster] step certificate create root.linkerd.cluster.local ca.crt ca.key \ --profile root-ca --no-password --insecure --not-after=87600h
[cluster] step certificate create identity.linkerd.cluster.local issuer.crt issuer.key \ --profile intermediate-ca --not-after 8760h --no-password --insecure \ --ca ca.crt --ca-key ca.keyroot.linkerd.cluster.localis the trust anchor’s common name. It is also the string we set as SPIRE’strust_domainin Part 3, so the SPIFFE IDs read consistently — but that is a convention, not a derivation: nothing reads a trust domain out of a certificate. What makes this one trust domain is that both sides chain to this root. (Linkerd’s own--identity-trust-domainstays at its default,cluster.local, which is why in-cluster identities look likelinkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.localin Part 4c.)- Linkerd requires ECDSA P-256 keys —
stepuses that profile by default. - Keep
ca.crtandca.keyhere in the cluster: the in-cluster SPIRE server mounts them (as a read-only Secret) to sign the external workload’s certificates. The key never goes to the store.
The two certs have distinct jobs. The issuer is what linkerd-identity uses to sign
in-cluster pod certs. The root is only the anchor — and it is also what the SPIRE
server (Part 3) chains to: SPIRE is configured with the root as its UpstreamAuthority
(SPIRE’s term for a CA sitting above the server), so on startup it mints its own
intermediate, signed by the root, and signs the external workload’s SVIDs with that. SPIRE
does not use the Linkerd issuer — it builds a separate, parallel intermediate. So one
root ends up with two independent signing paths beneath it:
root.linkerd.cluster.local ← trust anchor; every proxy pins this├── Linkerd issuer (issuer.crt) ← linkerd-identity signs in-cluster pod certs└── SPIRE server CA (minted in Part 3) ← signs the external workload's SVIDBoth paths’ leaf certs chain to the same root, so the mesh trusts them equally — that shared root is what lets an off-cluster identity work exactly like an in-cluster one.
Demo shortcut: this root is a password-less, 10-year, self-signed software root written straight to disk — quick to generate and inspect, but with no HSM, no encryption at rest, and no rotation or revocation plan. Real-world PKI keeps the root in an HSM or managed CA, with rotation and cross-signing planned up front. See Production notes → Security shortcuts and Trust & CA.
Install Linkerd with these certs instead of generated ones. Linkerd needs the Gateway API CRDs present first:
[cluster] kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml[cluster] linkerd install --crds | kubectl apply -f -[cluster] linkerd install \ --identity-trust-anchors-file ca.crt \ --identity-issuer-certificate-file issuer.crt \ --identity-issuer-key-file issuer.key | kubectl apply -f -[cluster] linkerd check--identity-trust-anchors-file ca.crtpins the root every proxy in the cluster will trust. Because the in-cluster SPIRE server signs under the matchingca.key, the SVIDs it issues to the off-cluster workload chain to this root and the cluster accepts them.
Demo shortcut: the Gateway API CRDs and Linkerd here — and
stepand the SPIRE tooling in the prerequisites — are pulled from unpinned, unverified sources. Real systems pin exact versions and verify checksums and signatures for all tooling and images. See Production notes → Security shortcuts.
Install the viz extension now — Part 8’s verification uses linkerd viz tap, and
installing it before the workloads means their proxies are tap-enabled from the
start (installing viz later requires a kubectl rollout restart on each workload for
tap to see it):
[cluster] linkerd viz install | kubectl apply -f -Part 2 — The networking prerequisite (store)
Section titled “Part 2 — The networking prerequisite (store)”Before any of the identity setup (Parts 3 onward), the store’s proxy must be able to reach the cluster’s control plane and, later, its data-plane peers. Concretely the store needs:
- an L3 route to the cluster pod CIDR and service CIDR (k3s defaults
10.42.0.0/16and10.43.0.0/16), and - DNS resolution for
*.cluster.local(so it can findlinkerd-dst-headless,linkerd-policy, andretail-cloud).
How you satisfy this is up to your network (a flat LAN, a VPN, an overlay). On a flat LAN it’s a static route plus a resolver entry — nothing Linkerd-specific:
[store] sudo ip route add 10.42.0.0/16 via <cluster-host-ip>[store] sudo ip route add 10.43.0.0/16 via <cluster-host-ip>[store] sudo mkdir -p /etc/systemd/resolved.conf.d[store] printf '[Resolve]\nDNS=10.43.0.10\nDomains=~cluster.local\n' \ | sudo tee /etc/systemd/resolved.conf.d/cluster.conf[store] sudo systemctl restart systemd-resolved10.43.0.10 is the cluster’s CoreDNS ClusterIP (kubectl -n kube-system get svc kube-dns). The pod/service CIDRs and this DNS IP are k3s defaults, not portable —
on any other cluster, discover yours (kubectl -n kube-system get svc kube-dns for
CoreDNS; your distro’s config for the CIDRs) and substitute them wherever they recur
below (Part 4’s proxy reaches the control plane over these). The cluster host must have
IP forwarding on so it routes the store’s traffic into the pod network. This is the distinction between identity and
connectivity: SPIFFE provides identity; this step provides connectivity.
Demo shortcut: connectivity here is a static route to hardcoded pod/service CIDRs plus a hand-written resolver pointing at a hardcoded CoreDNS ClusterIP. Real deployments use a managed overlay/VPN (WireGuard, a Tailscale subnet router, cross-cluster CNI, or VPC peering) with real route management, and an HA, discovered DNS path. See Production notes → Networking.
Part 3 — An identity source: SPIRE server in the cluster, agent on the store
Section titled “Part 3 — An identity source: SPIRE server in the cluster, agent on the store”SPIRE has two roles. The server issues identities and holds signing authority; it runs in the cluster next to the rest of the control plane and never exposes its signing key to the store. The agent runs on the store, attests local processes, and hands them SVIDs the server issued — over a local socket. This is SPIRE’s normal topology, and it keeps the root key off the less-trusted store host.
3a. Deploy the SPIRE server (cluster)
Section titled “3a. Deploy the SPIRE server (cluster)”Run the server in the cluster, using the Linkerd root as its UpstreamAuthority so the
SVIDs it issues chain to the same anchor the mesh trusts. The root cert and key are
mounted as a read-only Kubernetes Secret — they stay in the cluster.
server.cfg (mounted from a ConfigMap):
server { bind_address = "0.0.0.0" bind_port = "8081" trust_domain = "root.linkerd.cluster.local" data_dir = "/run/spire/data" ca_ttl = "168h" default_x509_svid_ttl = "48h"}plugins { DataStore "sql" { plugin_data { database_type = "sqlite3" connection_string = "/run/spire/data/datastore.sqlite3" } } KeyManager "disk" { plugin_data { keys_path = "/run/spire/data/keys.json" } } NodeAttestor "join_token" { plugin_data {} } UpstreamAuthority "disk" { plugin_data { cert_file_path = "/run/spire/secret/ca.crt" # mounted from the Secret key_file_path = "/run/spire/secret/ca.key" } }}Deploy it as a StatefulSet with the root as a Secret and a NodePort the store’s agent can
reach (the demo does this in cluster/spire/), and restrict that NodePort to your overlay
(a host firewall rule scoped to the Tailscale interface):
[cluster] kubectl create namespace spire[cluster] kubectl -n spire create secret generic spire-upstream-ca \ --from-file=ca.crt=ca.crt --from-file=ca.key=ca.key # root stays here, in-cluster[cluster] kubectl -n spire create configmap spire-server-config --from-file=server.cfg[cluster] kubectl apply -f spire-server.yaml # StatefulSet + NodePort :30081Demo shortcut: the server’s datastore is SQLite, its signing keys live on disk (
KeyManager "disk"), and it runs as a single StatefulSet replica with no HA. A real SPIRE deployment uses a networked RDBMS with managed backups, a KMS/HSM-backed KeyManager, and an HA server behind a stable endpoint. See Production notes → SPIRE topology.
UpstreamAuthority "disk"is the key setting: SPIRE signs under Linkerd’s root, so every SVID chains to the anchor the mesh already trusts — without the root key ever leaving the cluster.
Demo shortcut: that root is delivered as a Kubernetes Secret and read from disk (
UpstreamAuthority "disk"). Keeping the key in-cluster is the honest boundary this demo draws, but a cluster Secret is not HSM or offline-root custody. Real deployments keep the root in external/offline PKI, an HSM, or Vault, and have SPIRE chain to a scoped intermediate rather than signing under the root directly. See Production notes → The ones that matter most and Trust & CA.
NodeAttestor "join_token"is how agents prove which node they are — a one-time enrollment token (3b).
Demo shortcut:
join_tokennode attestation is a one-time bearer token — legitimate, but real deployments usually bind enrollment to a hardware/device identity (TPM/DevID) or a cloud instance identity (aws_iid,gcp_iit,k8s_psat,x509pop). See Production notes → Attestation.
3b. Enroll the store’s agent (store)
Section titled “3b. Enroll the store’s agent (store)”The store runs the agent only. It authenticates the server with a pinned trust bundle — the server’s CA certificates, delivered out of band, so it trusts only the genuine server rather than trust-on-first-use (accepting whatever certificate it is handed on the first connection). And it proves itself to the server with a one-time join token — a single-use bearer secret, minted on the server and bound to this node’s SPIFFE ID, good for exactly one enrollment; afterwards the agent uses its own node certificate.
Export the server’s bundle and mint a token on the cluster, then copy the bundle to the store (only the public cert material and a single-use token leave the cluster — never the key):
[cluster] kubectl -n spire exec spire-server-0 -- /opt/spire/bin/spire-server bundle show > bundle.pem[cluster] kubectl -n spire exec spire-server-0 -- /opt/spire/bin/spire-server token generate \ -spiffeID spiffe://root.linkerd.cluster.local/store/042/agent# Copy BOTH public files to the store (only public material + a single-use token leave the cluster):# bundle.pem -> /opt/spire/certs/bundle.pem (the agent's pinned server bundle)# ca.crt -> /opt/spire/certs/ca.crt (from Part 1; the trust anchor the proxy reads in Part 4c)[store] sudo mkdir -p /opt/spire/certs[store] sudo chmod 644 /opt/spire/certs/ca.crt # public material; see belowagent.cfg on the store:
agent { data_dir = "/opt/spire/data/agent" trust_domain = "root.linkerd.cluster.local" server_address = "<cluster-node-addr>" server_port = 30081 trust_bundle_path = "/opt/spire/certs/bundle.pem" # pinned; no insecure_bootstrap}plugins { KeyManager "disk" { plugin_data { directory = "/opt/spire/data/agent" } } NodeAttestor "join_token" { plugin_data {} } WorkloadAttestor "unix" { plugin_data { discover_workload_path = true # required to emit the unix:path selector workload_size_limit = -1 # we don't use unix:sha256, so skip hashing } }}[store] sudo spire-agent run -config /opt/spire/agent.cfg -joinToken "$TOKEN" # (a service unit in practice)[store] sudo spire-agent healthcheck -socketPath /tmp/spire-agent/public/api.sockDemo shortcut: the agent’s pinned trust bundle is a file copied to the store by hand, and the agent runs as a bare process (the
# (a service unit in practice)note above) rather than under supervision. Real deployments distribute bundles via a SPIFFE trust-bundle endpoint / federation and run the agent as a supervised service (a systemd unit, or a DaemonSet for in-cluster agents) with restart policies. See Production notes → Trust & CA and SPIRE topology.
- The
spire-serverbinary inside the container lives at/opt/spire/bin/and is not on$PATH, so everykubectl exec … spire-servercommand here (and the registration in 3c) invokes it by full path. trust_bundle_pathpins the server: the agent authenticates the server’s certificate against this bundle.insecure_bootstrap(trust-on-first-use) is not used now that the agent talks to a remote server.- Both files are public certificate material — the secret half (
ca.key) stayed in the cluster in 3a — which is whyca.crtis made world-readable above: Part 4c reads it as the ordinary user launching the proxy, not as root. (If it stays root-only, that read silently yields an empty string and the proxy rejects it asInvalidTrustAnchors, which looks like a bad certificate rather than a bad permission.) - In this topology the two files are byte-identical, and it is worth knowing why: SPIRE chains straight to the Linkerd root, so the bundle it publishes is that root. The two roles stay distinct — one authenticates the server to the agent, the other authenticates peers to the proxy — and they would diverge the moment SPIRE chained to an intermediate instead.
discover_workload_path = trueis required for theunix:pathselector in 3c — without it the attestor never emits a path selector and attestation fails.- The agent exposes the SPIFFE Workload API on a local Unix socket
(
/tmp/spire-agent/public/api.sock); the proxy reads its identity from there. This socket stays local to the store — it is never exposed over the network.
3c. Register the workload with the SPIRE server (cluster)
Section titled “3c. Register the workload with the SPIRE server (cluster)”Registration tells the SPIRE server which identity a process may receive if it matches
a set of conditions (the selectors below) — here, the store’s proxy. It is an
administrative act an operator performs on the SPIRE server, not something the workload does,
and it is separate from telling Linkerd the workload exists (the ExternalWorkload, Part
5). Do it on the cluster:
[cluster] kubectl -n spire exec spire-server-0 -- /opt/spire/bin/spire-server entry create \ -parentID spiffe://root.linkerd.cluster.local/store/042/agent \ -spiffeID spiffe://root.linkerd.cluster.local/store/042/inventory-sync \ -selector unix:uid:2102 \ -selector unix:path:/opt/linkerd-proxy/linkerd-proxyDemo shortcut: each workload identity is registered by hand with
spire-server entry create. Real deployments drive registration from the SPIRE Controller Manager (ClusterSPIFFEIDCRDs) or a registrar/GitOps pipeline. See Production notes → Lifecycle & automation.
-spiffeID …/store/042/inventory-syncis the identity to grant.-parentID …/store/042/agentis the store’s agent (attested in 3b) that will deliver it.- The two selectors are the condition: the caller must be uid 2102 and the
proxy binary at that path. That is the
linkerd2-proxy, which runs as a dedicated non-root user (Part 4) and holds the SVID on the app’s behalf. This is least-privilege isolation between ordinary processes — an unrelated process (even one running as root) does not match, so it does not get this identity.
Demo shortcut:
unix:uid+unix:pathattestation isolates ordinary processes, but it is not a defense against a full root compromise of the store host, which could run the permitted binary as the permitted uid. See Production notes → Security boundaries and Attestation.
Part 4 — The data-plane proxy on the store
Section titled “Part 4 — The data-plane proxy on the store”4a. Get the proxy binary
Section titled “4a. Get the proxy binary”The standalone proxy is the same binary shipped in Linkerd’s sidecar image; extract it, matching the control-plane version exactly.
[store] sudo mkdir -p /opt/linkerd-proxy[store] id=$(sudo docker create cr.l5d.io/linkerd/proxy:$LINKERD_VERSION)[store] sudo docker cp "$id:/usr/lib/linkerd/linkerd2-proxy" /opt/linkerd-proxy/linkerd-proxy[store] sudo docker rm -v "$id"Demo shortcut: the proxy binary is hand-extracted from the image and kept in version-match by discipline (
$LINKERD_VERSION), and the image is pulled by tag rather than digest. Real deployments ship the proxy as a versioned, signed package with automated version-match checks, and pin images by digest. See Production notes → Lifecycle & automation and Security shortcuts.
4b. Redirect traffic through the proxy (iptables)
Section titled “4b. Redirect traffic through the proxy (iptables)”The proxy only helps if the workload’s traffic passes through it. On a bare host you can’t redirect “everything except the proxy” the way a pod’s network namespace does — that would also capture the SPIRE agent, DNS, SSH, and the proxy’s own egress and break the box. So the redirect is scoped to the app’s uid: only the store-pos app (uid 1000) has its outbound sent to the proxy; everything else on the host is left alone.
[store] sudo iptables -t nat -N PROXY_APP_OUTPUT[store] sudo iptables -t nat -A PROXY_APP_OUTPUT -o lo -j RETURN[store] sudo iptables -t nat -A PROXY_APP_OUTPUT -p tcp -j REDIRECT --to-port 4140[store] sudo iptables -t nat -A OUTPUT -m owner --uid-owner 1000 -p tcp -j PROXY_APP_OUTPUTThe invariant is simply: app uid 1000 → the proxy; all other host traffic → normal networking. Because only uid 1000 is redirected, there is no blanket root exemption and no setup-ordering dependency — the SPIRE agent (running as root) is never captured, so it can enroll before or after these rules are in place. (Pick a uid the app actually owns. On a normal Linux box 1000 is the primary human user, and redirecting it sends their shell’s traffic through the proxy too — fine on a throwaway VM, wrong on a real store host.)
It is worth being precise about what this buys, because the natural reading is too
generous. The redirect is not authentication. The party SPIRE attested is the proxy
(uid 2102, that binary — Part 3c); the app is never attested, never talks to SPIRE, and
never holds a key. What the redirect does is decide whose traffic gets carried under the
proxy’s identity — so anything the host will run as uid 1000 pushes as
store/042/inventory-sync, indistinguishably from the real app. The boundary on the store
host is who can run as that uid, not which program it is. That is a real boundary
between ordinary processes, and it is not a boundary against host root.
4c. Launch the proxy — identity from SPIRE
Section titled “4c. Launch the proxy — identity from SPIRE”The proxy is configured entirely through environment variables:
[store] export LINKERD2_PROXY_IDENTITY_SERVER_ID="spiffe://root.linkerd.cluster.local/store/042/inventory-sync"[store] export LINKERD2_PROXY_IDENTITY_SERVER_NAME="inventory-sync.cluster.local"[store] export LINKERD2_PROXY_POLICY_WORKLOAD='{"ns":"mixed-env","external_workload":"store-pos"}'[store] export LINKERD2_PROXY_DESTINATION_CONTEXT='{"ns":"mixed-env","nodeName":"store","external_workload":"store-pos"}'[store] export LINKERD2_PROXY_DESTINATION_SVC_ADDR="linkerd-dst-headless.linkerd.svc.cluster.local.:8086"[store] export LINKERD2_PROXY_DESTINATION_SVC_NAME="linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local"[store] export LINKERD2_PROXY_POLICY_SVC_ADDR="linkerd-policy.linkerd.svc.cluster.local.:8090"[store] export LINKERD2_PROXY_POLICY_SVC_NAME="linkerd-destination.linkerd.serviceaccount.identity.linkerd.cluster.local"[store] export LINKERD2_PROXY_IDENTITY_SPIRE_WORKLOAD_API_ADDRESS="unix:///tmp/spire-agent/public/api.sock"[store] export LINKERD2_PROXY_IDENTITY_TRUST_ANCHORS="$(cat /opt/spire/certs/ca.crt)"# Run the proxy as a dedicated non-root user (uid 2102), not root:[store] sudo useradd -M -u 2102 -s /usr/sbin/nologin linkerd-proxy[store] sudo -E setpriv --reuid=2102 --regid=2102 --clear-groups /opt/linkerd-proxy/linkerd-proxyWhat each group does:
IDENTITY_SERVER_ID/IDENTITY_SERVER_NAME— both describe this proxy as a server: the identity its own leaf certificate must carry (so it must match the registered entry), and the name clients put in the SNI extension when they dial it. They are the proxy-side half of theExternalWorkload’smeshTLS.identityandmeshTLS.serverName(Part 5) — which is why the two sides must agree.IDENTITY_SPIRE_WORKLOAD_API_ADDRESS— the key change: instead of talking to the in-clusterlinkerd-identityservice, the proxy fetches its SVID from the SPIRE agent’s Workload API socket. SPIRE attests the proxy (uid 2102 + binary path), matches the registration entry, and streams it a certificate — rotating it before expiry, with no restart.IDENTITY_TRUST_ANCHORS— the root the proxy validates peers against. It’s the sameca.crt, so it trusts everything else in the mesh.DESTINATION_SVC_ADDR/POLICY_SVC_ADDR— where the proxy reaches the control plane:linkerd-destination(service discovery / endpoints) on 8086 andlinkerd-policy(authorization policy) on 8090. These resolve via the cluster DNS and routes you set up in Part 2.POLICY_WORKLOAD/DESTINATION_CONTEXT— how the proxy identifies itself to those controllers: as the external workloadstore-posin namespacemixed-env. This is why theExternalWorkload(next part) must exist and match.
On startup, the log line to look for is Certified identity id=spiffe://…/store/042/inventory-sync,
which confirms SPIRE issued the SVID and the proxy has joined the mesh.
Next to it you will see a warning repeating on a backoff until Part 5 creates the
ExternalWorkload:
WARN watch{port=4191}: Unexpected policy controller response; retrying with a backoff grpc.status=Some requested entity was not found grpc.message="unknown server"Expected, and it stops the moment that resource exists. It is the proxy failing to fetch
inbound policy for itself — the direction the ExternalWorkload describes. Nothing on
the outbound push path consults it, which is why the store can already reach the cloud
while this warning is still looping.
Part 5 — Tell the cluster about the workload (ExternalWorkload)
Section titled “Part 5 — Tell the cluster about the workload (ExternalWorkload)”Back on the cluster side, register the store as an ExternalWorkload. This is how the
mesh knows the workload exists and how to route to it as a server — and it’s what the
POLICY_WORKLOAD reference above resolves against.
First create the namespace the external workload and the cloud app share, with Linkerd injection enabled — the cloud app (Part 6) must be meshed for the Part 7 identity policy to take effect:
[cluster] kubectl create namespace mixed-env[cluster] kubectl annotate namespace mixed-env linkerd.io/inject=enabledThen register the store as an ExternalWorkload:
apiVersion: workload.linkerd.io/v1beta1kind: ExternalWorkloadmetadata: name: store-pos namespace: mixed-env labels: app: store-pos # policy selectors match on this workload_name: store-posspec: meshTLS: identity: "spiffe://root.linkerd.cluster.local/store/042/inventory-sync" serverName: "inventory-sync.cluster.local" workloadIPs: - ip: "<store-host-ip>" ports: - port: 80 name: httpmeshTLS.identitymust equal the SPIFFE ID the proxy obtains, andmeshTLS.serverNametheIDENTITY_SERVER_NAMEit was launched with. Those are the two[store]settings from Part 4c, written here on the cluster side.workloadIPs/portsdescribe how to reach it as a server. In the push-only RetailCloud the store isn’t dialed by anyone, so this is nominal.
The resource does not confer the workload’s identity. That arrives in the SVID SPIRE
issues (Part 3) and travels in the certificate; Part 7’s policy matches the SPIFFE ID
string directly. Delete the ExternalWorkload and the store’s pushes still succeed, still
over mTLS, still attributed to store/042/inventory-sync. What the resource provides is
the cluster’s model of the workload — routing to it as an endpoint, and the object its
inbound policy attaches to. Both of those are the inbound direction, which is exactly
what the meshTLS block above describes and what this demo’s push-only flow never uses.
The endpoint is treated as NotReady until a Ready status condition exists; set it
(status is a subresource, so kubectl apply of the spec above won’t):
[cluster] kubectl -n mixed-env patch externalworkload store-pos --subresource=status --type=merge \ -p '{"status":{"conditions":[{"type":"Ready","status":"True","reason":"Manual","message":"demo","lastTransitionTime":"2026-01-01T00:00:00Z"}]}}'Demo shortcut: the workload’s
Readystatus is forced by hand with a hardcodedlastTransitionTime. Real systems drive readiness from real health signals, never a static forced condition. See Production notes → Lifecycle & automation.
Part 6 — The application and the data flow
Section titled “Part 6 — The application and the data flow”Two small services: the store service sends data, and the cloud service receives and displays it.
store-pos (store, non-root). A small HTTP client that maintains an
inventory/sales model and POSTs a snapshot to the cloud’s ingest endpoint every few
seconds.
It runs as uid 1000 in the host’s network namespace — as a container, that is
docker run --network host --user 1000 — and both halves are load-bearing. Part 4b’s rule
lives in the host’s nat table, so a container with a network namespace of its own never
meets it whatever uid it runs as: its POST would leave the box unproxied and with no
identity. With both, the POST is redirected through the proxy and carries the
store/042/inventory-sync SVID over mTLS. It resolves
retail-cloud.mixed-env.svc.cluster.local via the cluster DNS from Part 2.
retail-cloud (cluster, a normal meshed pod). Listens on two ports:
:8080— the browser dashboard and its/api/data. No policy on this port, so the (unmeshed) browser can load it.:8090— the meshed ingest endpoint the store pushes to. This is the port we protect by identity in Part 7.
Demo shortcut: the
:8080dashboard and its/api/dataare served over plain HTTP with no TLS and no authentication, so an unmeshed browser can load them directly. Real systems terminate TLS at an ingress/gateway and require auth for the UI and any control endpoints. See Production notes → Security shortcuts.
It caches the latest report and renders it. When the store’s pushes are refused, the cached data stops updating, which is the behavior a real ingest pipeline would show.
Part 7 — Authorization by identity
Section titled “Part 7 — Authorization by identity”By default a meshed port is open to any client — Linkerd’s default inbound policy is
all-unauthenticated, so being in the mesh is not itself an authorization gate (which is
why the unmeshed browser can load :8080). We make the ingest port
default-deny except for the store’s identity with three policy resources on the
cluster side:
# 1. Declare the protected port. Creating a Server flips :8090 to default-deny.apiVersion: policy.linkerd.io/v1beta3kind: Servermetadata: { namespace: mixed-env, name: retail-ingest }spec: podSelector: { matchLabels: { app: retail-cloud } } port: ingest proxyProtocol: HTTP/1---# 2. Name the identity/identities allowed to authenticate.apiVersion: policy.linkerd.io/v1alpha1kind: MeshTLSAuthenticationmetadata: { namespace: mixed-env, name: allow-store }spec: identities: - "spiffe://root.linkerd.cluster.local/store/042/inventory-sync"---# 3. Bind them: this Server requires that authentication.apiVersion: policy.linkerd.io/v1alpha1kind: AuthorizationPolicymetadata: { namespace: mixed-env, name: ingest-allow-store }spec: targetRef: { group: policy.linkerd.io, kind: Server, name: retail-ingest } requiredAuthenticationRefs: - { group: policy.linkerd.io, kind: MeshTLSAuthentication, name: allow-store }- The
Serverboth selects the workload+port and turns it default-deny. - The
MeshTLSAuthenticationlists allowed identities — here the store’s SPIFFE ID verbatim. (You can list several, or use ServiceAccount refs for in-cluster clients.) - The
AuthorizationPolicyties them together: to reachretail-ingest, a client must present one of those identities over mTLS.
Revoking access is now a one-line policy change — swap the allowed identity for a different one:
[cluster] kubectl -n mixed-env patch meshtlsauthentication allow-store --type=merge \ -p '{"spec":{"identities":["spiffe://root.linkerd.cluster.local/nobody"]}}'The store’s next push returns 403, even though its route, address, and firewall
are unchanged. In the demo, the dashboard’s Void authorization button applies this
same patch, using an RBAC Role granted to retail-cloud.
Demo shortcut: that Void authorization button lets the served
retail-cloudapp patch the veryMeshTLSAuthenticationthat protects it — RBAC to rewrite its own authorization policy, reachable from an unauthenticated browser endpoint. This is the demo’s headline simplification: vivid for teaching, but it means the workload can rewrite the policy that governs it, and any code-execution bug in the app inherits that power. Real systems author authorization policy in a source of truth, never mutated by the workload it governs, and never grant an app write access to its own policy. See Production notes → The ones that matter most and Security shortcuts.
Part 8 — Verify
Section titled “Part 8 — Verify”Watch the identities on the wire from the cluster side:
[cluster] linkerd -n mixed-env viz tap -o wide deploy/retail-cloudEach inbound row shows tls=true, and with -o wide,
src_client_id=spiffe://root.linkerd.cluster.local/store/042/inventory-sync — the
store’s identity, on traffic arriving from the external host outside Kubernetes,
authenticated by SPIFFE as it pushes (-o wide also shows the dst_srv_name and
dst_authz_name that admitted it). Apply the revoke patch above and the rows become
403; restore it and they return to 200. Nothing about the network moved — only
which identity was allowed.
What each part provides
Section titled “What each part provides”| Piece | What it provides the workload |
|---|---|
Shared trust anchor (Part 1) + SPIRE UpstreamAuthority (Part 3) |
a certificate the cluster trusts |
SPIRE registration + unix attestor (Part 3) |
a specific identity tied to the correct process |
| SPIRE Workload API + proxy env (Parts 3–4) | automatic issuance and rotation of that identity |
| iptables + non-root workload (Part 4) | its traffic passing through the proxy |
ExternalWorkload (Part 5) |
a representation in the mesh’s model of the cluster |
Server + MeshTLSAuthentication + AuthorizationPolicy (Part 7) |
access decided by identity rather than location |
The final row is the purpose of the setup: when identity is portable and cryptographic, authorization is defined in terms of workloads rather than networks.