Use Tailscale for private access rather than a bastion
Status: Accepted Date: 2026-08-21 Deciders: Smana (Platform Owner) Related Design: N/A — records a choice predating the design workflow
Context
The EKS API endpoint is private, and so is every operational service this platform runs: Harbor, Grafana, VictoriaMetrics, VictoriaLogs, Headlamp, Homepage, Hubble UI, and OpenBao’s own API and Raft peers. None of it has a public IP. Something still has to let a human, or a CI runner, reach all of that without exposing a listener to the internet.
The classic answer is a bastion host: one EC2 instance with a public IP,
inbound SSH restricted by security group, and a fleet of operator SSH keys
authorized on it. This platform takes a different path: every private
service is reached over a Tailscale tailnet, with two Gateway API
Gateways and a subnet router as the only entry points, and access
controlled entirely by Tailscale ACL tags evaluated outside Kubernetes.
Decision Drivers
- No public attack surface. Nothing private should have a listening public IP, ever — not even one gated by a security group and patch cadence.
- Identity-based authorization, not network-based. Access should be a function of who is connecting, not where the packet came from.
- No long-lived credentials to rotate. SSH key distribution and revocation across a fleet of operators is itself an ongoing cost.
- Two audiences, two trust levels. General platform services (Harbor,
Grafana) and operational tooling (Hubble UI,
kube-system) need different reachability — everyone on the tailnet for the former, only admins for the latter. - Fits the existing GitOps/Gateway API model. The platform’s only
ingress mechanism is Gateway API; whatever provides private access
should plug into a
GatewayClass, not bolt on a separate proxy layer.
Considered Options
Option 1: Tailscale
A mesh VPN (WireGuard-based) with identity-backed ACLs. The Kubernetes
Tailscale Operator (security/base/tailscale-operator/) exposes two
Gateway API Gateways as Tailscale devices; a separate subnet-router EC2
instance (opentofu/network/tailscale.tf) advertises the VPC CIDR for
direct access to the EKS API and OpenBao.
Pros:
- Identity-based ACLs (
tailscale_aclinopentofu/network/tailscale.tf) replace network-perimeter trust: a device’s tag, not its source IP, decides what it can reach. - Tailscale SSH (
ssh { action = "check", src = ["autogroup:member"], dst = ["autogroup:self"] }) gives the subnet router keyless, ephemeral, per-connection SSH — no staticauthorized_keysto maintain. - Plugs directly into Gateway API via
CiliumGatewayClassConfigandloadBalancerClass: tailscale— no separate ingress mechanism to run. - The subnet router sits in a private subnet with no public IP; the only thing internet-reachable is Tailscale’s own coordination service, which this platform does not operate.
Cons:
- A third-party control plane now sits on the path to a private cluster
(see
### Negative). - Still one EC2 instance to size and patch (the subnet router) — this removes the bastion, not all infrastructure with a Tailscale connection.
- A new mechanism (ACL tags,
tagOwners) for operators to learn, instead of the SSH/security-group model most platform teams already know.
Option 2: Bastion host + SSH
One EC2 instance with a public (or NAT-forwarded) IP, security-group-gated inbound SSH, and operator keys authorized on it as a jump box to the VPC.
Pros:
- No third-party control-plane dependency — reachability depends only on AWS and the instance itself.
- Familiar operational model; no new tool for operators to adopt.
Cons:
- A host with a public IP is a permanent target; patch cadence becomes a security-critical operational duty, not an optional one.
- SSH key distribution and revocation across every operator is a standing cost with no built-in expiry.
- Authorization is per-host (who can SSH to the bastion), not per-service — no equivalent to the general/admin Gateway split without building it.
- No native integration with Gateway API; reaching Kubernetes-native
services (Grafana, Harbor) through a bastion means a second hop (SSH
tunnel or
kubectl port-forward), not a browser-navigable hostname.
Option 3: AWS Client VPN
A managed VPN appliance (aws_ec2_client_vpn_endpoint) terminating in the
VPC, authenticated via certificates or SAML/IdP federation.
Pros:
- Fully AWS-managed — no third-party coordination server, no extra instance to patch.
- Certificate or IdP-based auth, no static SSH keys.
Cons:
- Priced per connection-hour and per associated subnet — an ongoing AWS bill scaling with usage, versus Tailscale’s flat/free-tier model for a tailnet this size.
- Still network-perimeter trust once connected (a VPN client is “in the VPC”), not the per-device ACL-tag model this platform standardizes on for Kubernetes-native private services.
- No Gateway API integration — reaching private services still means a second mechanism on top of the VPN tunnel.
Decision Outcome
Chosen option: “Option 1 — Tailscale”
Rationale: The platform’s private-access requirement is not just “reach
the VPC” — it is “expose two classes of Kubernetes-native services with
different trust levels, plus the private EKS API endpoint, without a
publicly reachable listener on the private-access path.” Tailscale is the
only option that satisfies all of that with one mechanism: ACL tags
authorize both the Gateway API split (general vs. admin) and the subnet
router, tagOwners puts tag assignment solely in the Kubernetes Tailscale
Operator’s hands so no human or other automation can self-escalate, and
nothing in the path carries a public IP. A bastion solves reachability but
not the service-level trust split, and reintroduces the exact
public-listener and SSH-key-rotation costs this decision exists to avoid.
AWS Client VPN avoids the public listener but keeps network-perimeter trust
and buys none of the Gateway API integration.
Consequences
Positive
- ACL tags are the authorization primitive end to end:
tag:k8son the generalGateway(platform-tailscale-general) is reachable by every tailnet member;tag:adminon the adminGateway(platform-tailscale-admin) is reachable only bygroup:admin. Both setloadBalancerClass: tailscalethrough the sharedCiliumGatewayClassConfig(infrastructure/base/gapi/), andtagOwnersrestricts who can apply either tag to the Kubernetes Tailscale Operator alone (opentofu/network/tailscale.tf). - The two-gateway split makes admin-only services (Hubble UI, and anything
else parented to
platform-tailscale-admin) genuinely unreachable for a non-admin device — not merely unlisted or unlinked. Atag:k8s-only device gets no route toplatform-tailscale-adminregardless of whether it knows the hostname. - The EKS API endpoint is private with no other path in: Tailscale, via the subnet-router’s VPC-CIDR advertisement, is how it is reached at all.
- No bastion host to patch: the subnet router carries no public IP and no inbound SSH listener open to arbitrary sources, and Tailscale SSH removes the need for any long-lived operator SSH key.
Negative
- A third-party dependency sits on the control path to a private cluster. If Tailscale’s coordination server is unavailable, devices already connected keep their existing peer-to-peer sessions, but new connections and re-authentications cannot be established — an outage there degrades this platform’s only path to its private services.
- No fallback if the Tailscale path to a service breaks. Because
Tailscale is the sole mechanism (no parallel bastion, no public
allowlisted IP), a misconfigured ACL, a stalled subnet router, or a
tailnet-side DNS problem leaves an operator with no alternate route in —
the Get Started → Access
page documents only the working path, not a fallback one.
- Mitigation: the subnet-router module runs
prometheus_enablednode metrics and the platform’s own alerting; a stuck router is expected to surface there before it’s discovered by a locked-out operator.
- Mitigation: the subnet-router module runs
TF_VAR_tailscale_api_keyis the one secret the bootstrap cannot source from AWS Secrets Manager — it has to exist before OpenTofu can create anything, including the Secrets Manager access path itself, so it is supplied by hand as an environment variable (see the repository README). Every other Tailscale credential — the Kubernetes Operator’s OAuth client — is ESO-managed from OpenBao like any other secret (security/base/tailscale-operator/oauth-client-externalsecret.yaml); this one bootstrap key is the sole exception to that pattern.
Neutral
- The subnet router is still a real EC2 instance with its own capacity
planning —
opentofu/network/tailscale.tfpinst3.microexplicitly after ant3a.microcapacity failure ineu-west-3. Removing the bastion did not remove all patchable infrastructure from the access path, only the public-IP, SSH-key-fleet version of it. - Two independent Tailscale-facing surfaces exist for two different jobs: the Kubernetes Tailscale Operator (Gateway API devices, OAuth-client authenticated) and the OpenTofu-provisioned subnet router (VPC-CIDR routing, API-key authenticated at creation). They are not redundant — each is the only path to what it serves.
Implementation Notes
The Kubernetes-side Tailscale Operator (security/base/tailscale-operator/)
owns the two Gateway API Gateway devices and nothing else; it does not
advertise routes into the VPC. The OpenTofu-side subnet router
(opentofu/network/tailscale.tf, module Smana/tailscale-subnet-router/aws)
owns VPC-CIDR advertisement and nothing else; it does not front any
Kubernetes Service. Reaching a Kubernetes-native private service always
goes through a Gateway/HTTPRoute; reaching the VPC directly (the EKS
API, OpenBao’s Raft peers) always goes through the subnet router. The two
mechanisms are not interchangeable, and conflating them when adding a new
private service is the most common way to reach for the wrong one.
References
- Platform → Networking → Private access — the ACL model, the Gateway wiring, and the subnet router in full
- Get Started → Access — the operator-facing walkthrough this ADR’s Negative section references
opentofu/network/tailscale.tf— thetailscale_aclresource,tagOwners, and the subnet-router module blocksecurity/base/tailscale-operator/— the Kubernetes Tailscale Operator HelmRelease,ProxyClasses, and OAuth-clientExternalSecretinfrastructure/base/gapi/— the two TailscaleGateways and the sharedCiliumGatewayClassConfig- ADR-0015 — the Gateway API routing mechanics behind the
CiliumGatewayClassConfigandloadBalancerClass: tailscalewiring described here - Tailscale: Simplifying Cloud Access — the platform author’s long-form writeup of this same choice
- Tailscale ACLs — the tag/
tagOwnersmodel this ADR relies on - Tailscale architecture — the coordination-server dependency behind this ADR’s first
### Negativepoint