Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions terraform/aws/aws-eks-operator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# aws-eks-tailscale-operator

This example creates the following:

- a VPC and related resources including a NAT Gateway
- an EKS cluster with a managed node group
- a Kubernetes namespace for the [Tailscale operator](https://tailscale.com/kb/1236/kubernetes-operator)
- the Tailscale Kubernetes Operator deployed via [Helm](https://tailscale.com/kb/1236/kubernetes-operator#helm)

## Considerations

- The EKS cluster is configured with both public and private API server access for flexibility
- The Tailscale operator is deployed in a dedicated `tailscale` namespace
- The operator will create a Tailscale device for API server proxy access
- Any additional Tailscale resources (like ingress controllers) created by the operator will appear in your Tailnet

## Prerequisites

- Create a [Tailscale OAuth Client](https://tailscale.com/kb/1215/oauth-clients#setting-up-an-oauth-client) with appropriate scopes
- Ensure you have AWS CLI configured with appropriate permissions for EKS
- Install `kubectl` for cluster access after deployment

## To use

Follow the documentation to configure the Terraform providers:

- [AWS](https://registry.terraform.io/providers/hashicorp/aws/latest/docs)
- [Kubernetes](https://registry.terraform.io/providers/hashicorp/kubernetes/latest/docs)
- [Helm](https://registry.terraform.io/providers/hashicorp/helm/latest/docs)

### Configure variables

Create a `terraform.tfvars` file with your Tailscale OAuth credentials:

```hcl
tailscale_oauth_client_id = "your-oauth-client-id"
tailscale_oauth_client_secret = "your-oauth-client-secret"
```

### Deploy

```shell
terraform init
terraform apply
```

#### Verify deployment

After deployment, configure kubectl to access your cluster:

```shell
aws eks update-kubeconfig --region $AWS_REGION --name $(terraform output -raw cluster_name)
```

Check that the Tailscale operator is running:

```shell
kubectl get pods -n tailscale
kubectl logs -n tailscale -l app.kubernetes.io/name=tailscale-operator
```

#### Verify connectivity via the [API server proxy](https://tailscale.com/kb/1437/kubernetes-operator-api-server-proxy)

After deployment, configure kubectl to access your cluster using Tailscale:

```shell
tailscale configure kubeconfig ${local.operator_name}
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable reference ${local.operator_name} in the README will not be interpolated when users read the documentation. This should be replaced with a concrete example value or instruction like "example-aws-eks-operator-operator" or use the actual terraform output command reference.

Suggested change
tailscale configure kubeconfig ${local.operator_name}
tailscale configure kubeconfig $(terraform output -raw operator_name)

Copilot uses AI. Check for mistakes.
```

```shell
kubectl get pods -n tailscale
```

## To destroy

```shell
terraform destroy
```
152 changes: 152 additions & 0 deletions terraform/aws/aws-eks-operator/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
locals {
name = "example-${basename(path.cwd)}"

aws_tags = {
Name = local.name
}

// Modify these to use your own VPC
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets

# EKS cluster configuration
cluster_version = "1.34" # TODO: omit this?
node_instance_type = "t3.medium"
desired_size = 2
max_size = 2
min_size = 1

# Tailscale Operator configuration
operator_name = "${local.name}-operator"
operator_version = "1.92.4"
tailscale_oauth_client_id = var.tailscale_oauth_client_id
tailscale_oauth_client_secret = var.tailscale_oauth_client_secret
}

// Remove this to use your own VPC.
module "vpc" {
source = "../internal-modules/aws-vpc"

name = local.name
tags = local.aws_tags
}

module "eks" {
source = "terraform-aws-modules/eks/aws"
version = ">= 21.0, < 22.0"

name = local.name
kubernetes_version = local.cluster_version

addons = {
coredns = {}
eks-pod-identity-agent = {
before_compute = true
}
kube-proxy = {}
vpc-cni = {
before_compute = true
}
}

# Once the Tailscale operator is installed, `endpoint_public_access` can be disabled.
# This is left enabled for the sake of easy adoption.
endpoint_public_access = true

# Optional: Adds the current caller identity as an administrator via cluster access entry
enable_cluster_creator_admin_permissions = true

vpc_id = local.vpc_id
subnet_ids = local.subnet_ids

eks_managed_node_groups = {
main = {
# Starting on 1.30, AL2023 is the default AMI type for EKS managed node groups
# ami_type = "AL2023_x86_64_STANDARD"
instance_types = [local.node_instance_type]

desired_size = local.desired_size
max_size = local.max_size
min_size = local.min_size
}
}

tags = local.aws_tags
}

# Kubernetes namespace for Tailscale operator
resource "kubernetes_namespace_v1" "tailscale_operator" {
metadata {
name = "tailscale"
labels = {
"pod-security.kubernetes.io/enforce" = "privileged"
}
}
}

resource "helm_release" "tailscale_operator" {
name = local.operator_name
namespace = kubernetes_namespace_v1.tailscale_operator.metadata[0].name

repository = "https://pkgs.tailscale.com/helmcharts"
chart = "tailscale-operator"
version = local.operator_version

values = [
yamlencode({
operatorConfig = {
image = {
repo = "tailscale/k8s-operator"
tag = "v${local.operator_version}"
}
hostname = local.operator_name
}
apiServerProxyConfig = {
mode = "true"
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The apiServerProxyConfig mode should be a boolean value, not a string. Change mode = "true" to mode = true to ensure proper type handling.

Suggested change
mode = "true"
mode = true

Copilot uses AI. Check for mistakes.
tags = "tag:k8s-operator,tag:k8s-api-server"
}
oauth = {
clientId = local.tailscale_oauth_client_id
clientSecret = local.tailscale_oauth_client_secret
}
Comment on lines +108 to +111
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sensitive values are already being set through the set_sensitive block below (lines 115-124). Including them in the yamlencode values block on lines 109-110 will expose these sensitive values in the Terraform state and logs. Remove the oauth block from the yamlencode section since set_sensitive will handle these values securely.

Suggested change
oauth = {
clientId = local.tailscale_oauth_client_id
clientSecret = local.tailscale_oauth_client_secret
}

Copilot uses AI. Check for mistakes.
})
]

set_sensitive = [
{
name = "oauth.clientId"
value = local.tailscale_oauth_client_id
},
{
name = "oauth.clientSecret"
value = local.tailscale_oauth_client_secret
},
]
}

# TODO: get working on first apply?
# locals {
# # TODO: inline/simplify?
# yaml_tailscale_operator_ha_proxy = <<-EOT
# apiVersion: tailscale.com/v1alpha1
# kind: ProxyGroup
# metadata:
# name: ${helm_release.tailscale_operator.name}-ha
# spec:
# type: kube-apiserver
# replicas: 2
# tags: ["tag:k8s"]
# kubeAPIServer:
# mode: auth
# EOT
# }

# resource "kubernetes_manifest" "tailscale_operator_ha_proxy" {
# manifest = yamldecode(local.yaml_tailscale_operator_ha_proxy)

# depends_on = [
# module.eks.cluster_endpoint, # TODO: remove?
# helm_release.tailscale_operator,
# kubernetes_namespace_v1.tailscale_operator,
# ]
# }
24 changes: 24 additions & 0 deletions terraform/aws/aws-eks-operator/outputs.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
output "vpc_id" {
description = "VPC ID where the EKS cluster is deployed"
value = module.vpc.vpc_id
}

output "cluster_name" {
description = "EKS cluster name"
value = module.eks.cluster_name
}

output "tailscale_operator_namespace" {
description = "Kubernetes namespace where Tailscale operator is deployed"
value = kubernetes_namespace_v1.tailscale_operator.metadata[0].name
}

output "cmd_kubeconfig_tailscale" {
value = "tailscale configure kubeconfig ${local.operator_name}"
}

output "cmd_kubeconfig_aws" {
value = "aws eks update-kubeconfig --region ${data.aws_region.current.region} --name ${module.eks.cluster_name}"
Comment on lines +17 to +21
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing description for output. All outputs should include a description field to document their purpose and usage, especially for user-facing command outputs.

Suggested change
value = "tailscale configure kubeconfig ${local.operator_name}"
}
output "cmd_kubeconfig_aws" {
value = "aws eks update-kubeconfig --region ${data.aws_region.current.region} --name ${module.eks.cluster_name}"
description = "Command to configure kubeconfig for the EKS cluster using the Tailscale operator"
value = "tailscale configure kubeconfig ${local.operator_name}"
}
output "cmd_kubeconfig_aws" {
description = "AWS CLI command to configure kubeconfig for the EKS cluster"
value = "aws eks update-kubeconfig --region ${data.aws_region.current.region} --name ${module.eks.cluster_name}"

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +21
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing description for output. All outputs should include a description field to document their purpose and usage, especially for user-facing command outputs.

Suggested change
value = "tailscale configure kubeconfig ${local.operator_name}"
}
output "cmd_kubeconfig_aws" {
value = "aws eks update-kubeconfig --region ${data.aws_region.current.region} --name ${module.eks.cluster_name}"
description = "Command to configure kubectl using the Tailscale operator"
value = "tailscale configure kubeconfig ${local.operator_name}"
}
output "cmd_kubeconfig_aws" {
description = "AWS CLI command to configure kubectl for this EKS cluster"
value = "aws eks update-kubeconfig --region ${data.aws_region.current.region} --name ${module.eks.cluster_name}"

Copilot uses AI. Check for mistakes.
}

data "aws_region" "current" {}
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The data source is defined in the outputs file but would be more appropriately placed in a data.tf file or at the beginning of main.tf. Placing data sources at the end of an outputs file is unconventional and can make the code harder to maintain.

Copilot uses AI. Check for mistakes.
21 changes: 21 additions & 0 deletions terraform/aws/aws-eks-operator/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
variable "tailscale_oauth_client_id" {
description = "Tailscale OAuth client ID"
type = string
sensitive = true

validation {
condition = length(var.tailscale_oauth_client_id) > 0
error_message = "Tailscale OAuth client ID must not be empty."
}
}

variable "tailscale_oauth_client_secret" {
description = "Tailscale OAuth client secret"
type = string
sensitive = true

validation {
condition = length(var.tailscale_oauth_client_secret) > 0
error_message = "Tailscale OAuth client secret must not be empty."
}
}
42 changes: 42 additions & 0 deletions terraform/aws/aws-eks-operator/versions.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
terraform {
required_version = ">= 1.0"

required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.0, < 7.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 3.0.1, < 4.0"
}
helm = {
source = "hashicorp/helm"
version = ">= 3.1.1, < 4.0"
}
}
}

provider "kubernetes" {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)

exec {
api_version = "client.authentication.k8s.io/v1beta1"
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API version "client.authentication.k8s.io/v1beta1" is deprecated. Kubernetes 1.24+ uses "client.authentication.k8s.io/v1" as the stable API version. Since the cluster version in main.tf is set to "1.34", this should be updated to use the stable v1 API version.

Copilot uses AI. Check for mistakes.
command = "aws"
args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
}
}

provider "helm" {
kubernetes = {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)

exec = {
api_version = "client.authentication.k8s.io/v1beta1"
Copy link

Copilot AI Dec 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API version "client.authentication.k8s.io/v1beta1" is deprecated. Kubernetes 1.24+ uses "client.authentication.k8s.io/v1" as the stable API version. Since the cluster version in main.tf is set to "1.34", this should be updated to use the stable v1 API version.

Copilot uses AI. Check for mistakes.
command = "aws"
args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
}
}
}
Loading