A Kubernetes operator that runs WireGuard gateways on cloud VMs to give a private
or NAT'd cluster public ingress without exposing a cloud LoadBalancer of its own.
You apply a namespaced Gateway custom resource and the operator provisions a
dedicated gateway VM, dials it from inside the cluster, and forwards the public
TCP/UDP ports you list to your in-cluster Services. GCP is the cloud backend
implemented today.
Use it when a cluster cannot accept inbound connections directly: on-prem or NAT'd clusters, a homelab behind a residential ISP, anything with outbound-only egress. You still get stable public endpoints in front of cluster workloads.
Each Gateway reconciles two halves. A Crossplane composition provisions a GCP
VM running WireGuard and nftables: it holds the public IP and opens the listed
ports. An in-cluster gateway-link Deployment peers with that VM and DNATs the
forwarded ports to the backend Services. Because the cluster only ever dials
outbound, no inbound firewall rule is needed cluster-side. When dnsHostnames is
set, the operator publishes a DNSEndpoint pointing those names at the VM's
public IP for external-dns to serve.
A minimal Gateway:
apiVersion: wgnet.dev/v1alpha1
kind: Gateway
metadata:
name: edge
namespace: my-app
spec:
gcp:
projectID: my-gcp-project
region: us-central1
zone: us-central1-a
forwards:
- port: 443
protocol: TCP
service: my-appThis gives my-app a public endpoint on a cloud VM that forwards port 443 to the
in-cluster Service. See Creating a gateway for the full
spec.
client
│
│ public internet
▼
┌─────────────────────────────┐
│ cloud gateway VM │
│ public IP : port │
│ WireGuard + nftables │
└──────────────┬──────────────┘
│ WireGuard tunnel
│ (cluster dials outbound)
▼
┌─────────────────────────────┐
│ gateway-link Deployment │
│ DNAT port → targetPort │
└──────────────┬──────────────┘
│ ClusterIP
▼
backend Service ──▶ pods
Gateway-VM provisioning is backend-specific, and only GCP exists today.
| Backend | Status |
|---|---|
| GCP | Implemented |
| AWS | Not yet implemented |
| Requirement | Notes | Docs |
|---|---|---|
| Kubernetes cluster + kubectl | Any conformant cluster; typically one that cannot expose its own LoadBalancer. | kubectl |
| Privileged pods in gateway namespaces | The link pod runs a privileged init container to enable IPv4 forwarding; the namespace where you create a Gateway must not enforce restricted/baseline PodSecurity (label it pod-security.kubernetes.io/enforce: privileged if your cluster defaults to enforcement). |
Pod Security Admission |
| Helm | Installs Crossplane core and the operator chart. | Helm |
| Crossplane | Installed in the cluster; realizes the gateway VM composition. | Crossplane install |
| GCP provider (installed) | The Upbound provider-gcp packages, installed via Crossplane's package mechanism. | Crossplane providers |
| GCP provider (configured) | A ClusterProviderConfig with credentials.source=Secret referencing a service-account key. |
Provider authentication |
| GCP project + APIs | A project with billing and the compute, secretmanager, iam, and cloudresourcemanager APIs enabled. | gcloud CLI |
| GCP service-account key | A JSON key for a service account with the roles the composition needs, delivered as the Secret the ClusterProviderConfig references. |
Create SA key |
Install the upstream dependencies in order: Crossplane core, the GCP providers
and pipeline functions, then credentials and a ClusterProviderConfig. Finish
with the operator. The charts under k8s/infra/crossplane/
(crossplane-providers, crossplane-config) are E2E test scaffolding: they pin
package versions and add a CRD-readiness gate Job for make test-e2e, and are
not a production install path.
1. Crossplane core.
helm install crossplane crossplane \
--repo https://charts.crossplane.io/stable \
-n crossplane-system --create-namespace --wait2. GCP providers and pipeline functions.
kubectl apply -f - <<'EOF'
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
name: gcp-fast-poll
spec:
deploymentTemplate:
spec:
selector: {}
template:
spec:
containers:
- name: package-runtime
env:
- name: PROVIDER_POLL
value: "30s"
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-gcp-compute
spec:
package: xpkg.upbound.io/upbound/provider-gcp-compute:v2
runtimeConfigRef:
name: gcp-fast-poll
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-gcp-cloudplatform
spec:
package: xpkg.upbound.io/upbound/provider-gcp-cloudplatform:v2
runtimeConfigRef:
name: gcp-fast-poll
---
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-gcp-secretmanager
spec:
package: xpkg.upbound.io/upbound/provider-gcp-secretmanager:v2
runtimeConfigRef:
name: gcp-fast-poll
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-go-templating
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.12.1
---
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-auto-ready
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.6.5
EOF
kubectl wait --for=condition=Healthy provider.pkg.crossplane.io --all --timeout=5m
kubectl wait --for=condition=Healthy function.pkg.crossplane.io --all --timeout=5mThe providers track the floating :v2 major channel, since Upbound publishes no
:latest. The functions pin an explicit version because crossplane-contrib
publishes no floating tag. Confirm current versions on the
Upbound Marketplace (providers) and the
crossplane-contrib releases (functions).
The gcp-fast-poll DeploymentRuntimeConfig lowers the provider poll interval,
since the upstream default of 10m makes the multi-resource gateway provisioning
chain slow. Tune PROVIDER_POLL: lower is faster to provision at the cost of more
API calls. Do not declare a provider-family-gcp Provider explicitly: the leaf
providers pull the shared family in automatically, and declaring it duplicates
the family and breaks its RBAC.
3. Credentials and ProviderConfig. Load the service-account key as the
crossplane-system/gcp-creds Secret (key credentials.json). See
Configuring GCP credentials for obtaining the key
and for the declarative loading paths. The direct form is:
kubectl create secret generic gcp-creds -n crossplane-system \
--from-file=credentials.json=/path/to/gcp-key.json
kubectl apply -f - <<'EOF'
apiVersion: gcp.m.upbound.io/v1beta1
kind: ClusterProviderConfig
metadata:
name: default
spec:
projectID: YOUR_GCP_PROJECT_ID
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: gcp-creds
key: credentials.json
EOFThe ClusterProviderConfig name must be default: the gateway composition's GCP
managed resources reference providerConfigRef.name: default. It and the provider
CRDs must exist before any Gateway provisions.
4. The operator.
helm install wireguard-gateway-operator \
oci://ghcr.io/greg2010/wireguard-gateway-operator/charts/wireguard-gateway-operator \
--version 0.1.0 \
-n wireguard-gateway-operator --create-namespaceThe published chart pins the operator and link images, so no image values are
needed; override operator.image / link.image only if mirroring the images
elsewhere. The GCP project is set per Gateway via spec.gcp.projectID,
not on the chart. The two images are built from this repo's Dockerfile
(make docker-build-operator and make docker-build, override OPERATOR_IMAGE /
IMAGE to push registry-qualified tags).
The provider authenticates to GCP with a service-account JSON key. This section covers minting that key; loading it as the Secret is step 4 below.
The exported key is long-lived and the scripts do not rotate it. Periodically
mint a replacement (gcloud iam service-accounts keys create), update the Secret,
and delete the old key.
- Create a GCP project and enable the required APIs: compute, secretmanager, iam, cloudresourcemanager.
- Create a service account and grant it the roles the composition needs
(
compute.instanceAdmin.v1,compute.networkAdmin,compute.securityAdmin,iam.serviceAccountAdmin,iam.serviceAccountUser,secretmanager.admin). - Create a JSON key for that service account.
- Load the key into the cluster as the Secret above, using your cluster's
declarative secret mechanism (External Secrets Operator, Sealed Secrets,
or GitOps). The
ClusterProviderConfigthen reads it.
scripts/setup-gcp-project.sh performs step 1 (project and APIs),
scripts/setup-gcp-sa.sh performs step 2 (the service account and its roles),
and scripts/get-gcp-creds.sh performs step 3 (the key). All read configuration
from a .env file (see .env.example). They produce the GCP-side
credential but intentionally do not load it into a cluster. That is step 4.
Reference: create project, enable APIs, create service account, create SA key.
Apply a Gateway in the namespace whose Services you want to expose. provider
defaults to gcp. Each forward names a public port, a protocol, the bare
in-cluster service name, and an optional targetPort (defaults to port).
spec.gcp holds the GCP placement: projectID (required), region (required),
zone (required), and the defaulted machineType, image, diskSizeGB,
subnetCIDR, reservedIP, and spot. spec.wireguard holds the tunnel
parameters, all defaulted: listenPort (the gateway VM's WireGuard UDP port,
range 1–65535), subnet, gatewayAddress, linkAddress, keepalive, mtu,
and reconcileInterval. An omitted spec.wireguard yields the standard tunnel.
apiVersion: wgnet.dev/v1alpha1
kind: Gateway
metadata:
name: edge
namespace: my-app
spec:
gcp:
projectID: my-gcp-project
region: us-central1
zone: us-central1-a
machineType: e2-small
forwards:
- port: 443
protocol: TCP
service: my-app
targetPort: 8443
- port: 80
protocol: TCP
service: my-app
targetPort: 8081
wireguard:
listenPort: 51820
dnsHostnames:
- edge.example.comkubectl apply -f gateway.yaml
kubectl get gateway -n my-appNAME ADDRESS READY
edge 203.0.113.42 True
Forward validation is enforced at apply time and rejected by the Kubernetes API server:
- Each forward's
(port, protocol)combination must be unique. - A UDP forward must not use
spec.wireguard.listenPort. - At most 64 forwards per Gateway.
The ADDRESS column is the gateway VM's public IP, mirrored onto
status.address once provisioning completes; READY reflects the Ready
condition. With dnsHostnames set and external-dns running, the listed names
resolve to that IP.
A forward targets a Service in the Gateway's own namespace by default. To forward
into a different namespace, set forwards[].namespace, and the target namespace
must opt in by carrying the label wgnet.dev/allow-gateway-ingress: "true". This
consent gate prevents a Gateway owner from exposing another tenant's Service to
the public internet.
kubectl label namespace other-ns wgnet.dev/allow-gateway-ingress=true forwards:
- port: 8080
protocol: TCP
service: api
namespace: other-nsBackend Services of type ClusterIP and NodePort are supported (both carry a
routable ClusterIP to DNAT to). ExternalName and headless Services are
rejected: the Gateway reports Ready=False with reason UnsupportedServiceType.
Run make test for the full suite (unit, integration, e2e) or the per-suite
targets make test-unit / make test-integration / make test-e2e. The e2e
suite self-provisions a kind cluster, the full Crossplane stack, and a real GCP
gateway, so it requires the GCP configuration above. Local development needs
Go, kind,
and a container runtime — Docker or
Podman.
Apache License 2.0. See LICENSE.