This reference architecture demonstrates how to route all egress traffic from both a DigitalOcean Kubernetes (DOKS) cluster and standalone Droplets through a VPC NAT Gateway, providing a single static IP address for outbound connectivity.
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#0069FF', 'primaryTextColor': '#333', 'primaryBorderColor': '#0069FF', 'lineColor': '#0069FF', 'secondaryColor': '#F3F5F9', 'tertiaryColor': '#fff', 'fontFamily': 'arial'}}}%%
flowchart TB
subgraph Wrapper[" "]
direction TB
Internet(("Internet"))
subgraph DO["DigitalOcean Cloud"]
direction TB
subgraph VPC["VPC"]
direction TB
NATGW["NAT Gateway"]
subgraph DOKS["DOKS Cluster"]
RoutingAgent["Routing Agent"]
end
subgraph Droplets["Droplets"]
direction LR
Bastion["Bastion"]
Private["Private"]
end
BastionFW["Firewall<br/>(Allow SSH)"]
PrivateFW["Firewall<br/>(Allow RFC1918)"]
end
end
%% Outbound Internet traffic flow
DOKS -->|outbound<br/>traffic| NATGW
Private -->|outbound<br/>traffic| NATGW
NATGW -->|egress| Internet
%% Admin SSH access
Internet -->|admin SSH| Bastion
Bastion -->|SSH via<br/>private IP| Private
%% Firewall associations
BastionFW -.-> Bastion
PrivateFW -.-> Private
end
%% Styling - Subgraphs
style Wrapper fill:#FFFFFF,stroke:#FFFFFF
style DO fill:#E5E4E4,stroke:#0069FF,stroke-width:1px,stroke-dasharray:5
style VPC fill:#C6DDFF,stroke:#0069FF,stroke-width:1px,stroke-dasharray:5
style DOKS fill:#DCD1FF,stroke:#0069FF,stroke-width:1px
style Droplets fill:#B7EFFE,stroke:#0069FF,stroke-width:1px
%% Styling - Components
style Internet fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
style NATGW fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
style RoutingAgent fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
style Bastion fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
style Private fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
style BastionFW fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
style PrivateFW fill:#F3F5F9,stroke:#0069FF,stroke-width:2px
-
DigitalOcean VPC
- A VPC with a NAT Gateway providing centralized egress routing
- A DOKS cluster with the Routing Agent enabled to route pod traffic through the NAT Gateway
- A bastion droplet with default routing and public SSH access for administrative access
- A NAT-routed droplet configured via cloud-init to route all egress traffic through the NAT Gateway
-
Traffic Flow
- Cluster Pods: Route CRD configured via the DOKS Routing Agent overrides the default route to use the NAT Gateway
- NAT-Routed Droplet: Cloud-init configuration sets default route to NAT Gateway while preserving metadata access
- Bastion Droplet: Maintains default routing to provide SSH access to the NAT-routed droplet via VPC private IP
-
Security
- Bastion Firewall: SSH (port 22) accessible from anywhere (0.0.0.0/0)
- Private Firewall: SSH (port 22) only from RFC1918 private addresses (VPC, K8s clusters, peered networks)
- Tag-based firewall targeting for easy management
- Compliance Requirements: Organizations requiring traffic to originate from a known, static IP address
- API Allowlisting: Third-party services that require IP allowlisting for API access
- Audit Logging: Simplified egress traffic monitoring and logging from a single IP
- Cost Optimization: Reduce Reserved IP usage by consolidating egress through a single NAT Gateway
- Terraform v1.2+ installed
- DigitalOcean API Token (
DIGITALOCEAN_ACCESS_TOKENenvironment variable) doctlCLI for retrieving kubeconfig (optional)kubectlfor verification steps (optional)- SSH key configured in DigitalOcean account (for droplet access)
This reference architecture uses a two-stack deployment model:
Provisions the base infrastructure including VPC, NAT Gateway, DOKS cluster, bastion droplet, and NAT-routed droplet.
Applies the Route custom resource to configure cluster egress routing through the NAT Gateway. Stack 2 automatically reads all necessary outputs from Stack 1 via terraform_remote_state - no variables are required.
Important Notes:
- Two-stack architecture: Terraform cannot create a Kubernetes cluster and configure the Kubernetes provider to access that cluster in the same stack. Stack 2 uses a
data.digitalocean_kubernetes_clusterdata source to authenticate with the cluster created in Stack 1. - Production deployments: In real-world scenarios, you would typically apply the Route CRD using a YAML manifest with
kubectlor a GitOps tool rather than Terraform. Stack 2 exists primarily to demonstrate the complete end-to-end deployment in this reference architecture. - Single resource: Stack 2 contains only the Route CRD resource - it's a minimal stack that showcases how to programmatically configure the DOKS Routing Agent.
See the root README for general deployment guidance.
Deploy a test pod and check its egress IP:
# Deploy test pod
kubectl run test-egress --image=curlimages/curl:latest --restart=Never -- sh -c "curl -s ifconfig.me"
# Wait for completion
kubectl wait --for=condition=completed pod/test-egress --timeout=60s
# Check the egress IP
kubectl logs test-egress
# Clean up
kubectl delete pod test-egressExpected Result: The IP shown should match the nat_gateway_public_ip from Stack 1.
Access the NAT-routed droplet via the bastion and check its egress IP:
ssh -o ProxyCommand="ssh -W %h:%p root@<bastion_public_ip>" \
root@<droplet_private_ip> "curl -s ifconfig.me"Expected Result: The IP returned should match the same nat_gateway_public_ip.
Note: The NAT-routed droplet must be accessed via the bastion because all its egress traffic (including SSH return packets) routes through the NAT Gateway. This is the recommended pattern for accessing droplets behind NAT.
Check that the Route custom resource was created successfully:
kubectl get routes -A
kubectl describe route default-egress-via-nat| Name | Description | Type | Default | Required |
|---|---|---|---|---|
name_prefix |
Prefix for all resource names | string |
n/a | yes |
region |
DigitalOcean region slug | string |
n/a | yes |
vpc_cidr |
CIDR block for the VPC | string |
n/a | yes |
doks_cluster_subnet |
CIDR block for the DOKS cluster subnet | string |
n/a | yes |
doks_service_subnet |
CIDR block for the DOKS service subnet | string |
n/a | yes |
doks_node_count |
Number of nodes in the DOKS cluster | number |
1 |
no |
ssh_key_ids |
List of SSH key IDs or fingerprints for droplet access | list(string) |
[] |
no |
Note: The cluster nodes, bastion droplet, and NAT-routed droplet automatically use the latest Ubuntu LTS image and the most cost-effective droplet size with 2 vCPUs and 4GB memory available in the selected region.
Stack 2 requires no variables - it automatically reads all necessary values from Stack 1 via terraform_remote_state.
| Name | Description |
|---|---|
vpc_id |
ID of the VPC |
nat_gateway_id |
ID of the NAT Gateway |
nat_gateway_public_ip |
Public IP for egress traffic (what external services see) |
nat_gateway_gateway_ip |
VPC gateway IP for routing configuration |
cluster_id |
ID of the DOKS cluster |
cluster_name |
Name of the DOKS cluster |
bastion_public_ip |
Public IP of the bastion droplet |
bastion_private_ip |
Private IP of the bastion droplet |
droplet_public_ip |
Public IP of the NAT-routed droplet |
droplet_private_ip |
Private IP of the NAT-routed droplet |
| Name | Description |
|---|---|
route_name |
Name of the Route CRD that was created |
Resources must be destroyed in reverse order:
# First, destroy the routes (Stack 2)
cd terraform/2-routes
terraform destroy
# Then, destroy the infrastructure (Stack 1)
cd ../1-infra
terraform destroyImportant: The Route CRD must be removed before destroying the cluster to ensure clean deletion.
The NAT Gateway has two distinct IP addresses:
- Public IP (
nat_gateway_public_ip): The IP address that external services see when traffic egresses. Use this for allowlisting and verification. - Gateway IP (
nat_gateway_gateway_ip): The VPC routing table address. This is the IP used in the Route CRD and droplet routing configuration.
The bastion droplet provides secure SSH access to the NAT-routed droplet:
- Bastion: Has default routing and SSH accessible from the internet (0.0.0.0/0)
- NAT-Routed Droplet: All egress traffic routes through NAT Gateway, accessible only via bastion using SSH ProxyCommand
This pattern is recommended for production deployments where droplets need centralized egress but also require administrative access.
Two firewalls provide defense in depth:
- Bastion Firewall: Targets droplets with "bastion" tag; allows SSH from anywhere
- Private Firewall: Targets droplets with "private" tag; allows SSH only from RFC1918 addresses (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
Note: The DOKS cluster is protected by its own managed firewall and does not require a separate firewall resource like Droplets do.
Note: Even though a NAT Gateway is used for egress traffic, all Droplets and DOKS nodes still receive a public IP address. Inbound access to these resources is restricted by the firewalls described above.
The NAT-routed droplet's cloud-init configuration includes a critical route to preserve access to the DigitalOcean metadata service:
- to: 169.254.169.254/32
via: <original_gateway>This ensures the droplet can continue to access metadata even after changing the default route to the NAT Gateway.
NAT Gateways are region-specific. For multi-region architectures, deploy a separate NAT Gateway in each region's VPC.
- NAT Gateway: Charged per size unit. Each size unit provides 25 Mbps of symmetrical bandwidth and 100 GiB of outbound data transfer per month.
- DOKS Cluster: Based on node size and count
- Droplets: Based on size (bastion + NAT-routed)
- Data Transfer: Standard DigitalOcean egress rates apply for traffic exceeding the NAT Gateway's included transfer
See DigitalOcean Pricing for current rates.
- DOKS Routing Agent Documentation
- Configure DOKS for NAT Gateway
- Configure Droplets for NAT Gateway
- VPC NAT Gateway Resource
Symptom: kubectl get routes -A shows no routes
Solution: Verify the DOKS Routing Agent is enabled and running:
kubectl get pods -n kube-system | grep routing-agentSymptom: Droplet cannot access DigitalOcean metadata service
Solution: Verify the metadata route exists:
ip route | grep 169.254.169.254The route should show: 169.254.169.254 via <original_gateway> dev eth0
Symptom: curl ifconfig.me shows wrong IP
Solution:
- For cluster pods: Check Route CRD status and verify gateway IP is correct
- For droplets: Verify default route points to NAT Gateway:
ip route show defaultShould show: default via <nat_gateway_gateway_ip>
Symptom: Cannot SSH to NAT-routed droplet
Solution: Use the bastion as a jump host with ProxyCommand:
ssh -o ProxyCommand="ssh -W %h:%p root@<bastion_public_ip>" root@<droplet_private_ip>Ensure your SSH keys are specified in the ssh_key_ids var.