← All articles

ENGINEERING · AI VM SETUP BLUEPRINT

Inside the ingress split: two Traefik controllers and a WireGuard tunnel

Public websites and internal tools can share a VM without sharing an ingress controller. Here is how the blueprint’s GCP/k3s scaffold builds the two paths, how a request travels through WireGuard, and which checks make the separation credible.

WIREGUARD + TRAEFIK

One VM. Two access paths.

The private HTTPS connection travels inside WireGuard all the way to the VM.

PUBLIC

Website visitorwww.example.com
Internet · HTTPS
Public VM addressTCP 80 / 443 · ServiceLB
Public Service path
Traefik · publicingressClassName: traefik
Service → endpoints
Public appwww.example.com

PRIVATE · VIA VPN ONLY

Internal app usercrm.int.example.com → VPN IP
Encrypted WireGuard tunnel · UDP
WireGuard · VMDecrypts → tunnel IP:443
externalIPs → pod :8443
Traefik · privateingressClassName: internal
Service → endpoints
Private appcrm.int.example.com

On the VM: separate controllers and classes, reinforced by firewall rules and NetworkPolicies. No public application route to the private backend.

The concrete design

This article describes the shipped GCP/k3s scaffold, not a universal recipe for every Kubernetes network. The public path uses the Traefik controller bundled with k3s. The private path installs a separate, version-pinned Traefik Helm release in its own namespace. Example names below are traefik for the public IngressClass and internal for the private one.

Both controllers watch the namespaces they need, but select different ingress classes. Network reachability and backend policy complement that configuration. A private IP or a class name alone is not an authorization boundary.

Configure the bundled public controller

A HelmChartConfig named traefik in kube-system overrides the bundled chart. This preserves k3s ownership of the packaged component. The public class is explicit and non-default. The Kubernetes Ingress provider selects traefik; the CRD and Gateway providers are disabled in this baseline so additional route types cannot silently bypass the intended selection.

Public HTTP redirects to HTTPS through ports.web.http.redirections.entryPoint. The nested http key matters for the chart used by the scaffold. An accepted Helm configuration is not proof that the redirect exists: the verifier checks the rendered container arguments. The public service uses the k3s ServiceLB path; its precise packet path depends on the node and provider networking.

Configuration excerpt, not a complete deployment. Adapt placeholders and chart version to the environment.

# HelmChartConfig: kube-system/traefik — valuesContent excerpt
ingressClass:
  enabled: true
  isDefaultClass: false
  name: traefik
providers:
  kubernetesIngress:
    enabled: true
    ingressClass: traefik
    allowExternalNameServices: false
    allowEmptyServices: false
  kubernetesCRD:
    enabled: false
  kubernetesGateway:
    enabled: false

Install a separate private Helm release

The private release uses its own namespace and the internal class, also non-default. It enables the standard Ingress provider and disables the CRD and Gateway providers. ExternalName backends and empty services are disabled on both controllers in the scaffold.

The private Service is ClusterIP with externalIPs set to the WireGuard server’s tunnel address. Ports 80 and 443 map to Traefik’s container ports 8000 and 8443. The scaffold also sets svccontroller.k3s.cattle.io/enablelb to false; verification must confirm that no unintended private ServiceLB or public NodePort exposure exists.

externalIPs does not allocate an address or configure WireGuard. The tunnel address must already exist on the host and be routable from authorized peers. In the scaffold’s expected iptables path, kube-proxy’s KUBE-SERVICES rule handles that destination before CNI-HOSTPORT-DNAT. This ordering is checked, not assumed. An environment with a different dataplane needs its own validated address-binding design.

Configuration excerpt, not a complete deployment. Adapt placeholders and chart version to the environment.

# Private Helm release — valuesContent excerpt
ingressClass:
  enabled: true
  isDefaultClass: false
  name: internal
providers:
  kubernetesIngress:
    enabled: true
    ingressClass: internal
    allowExternalNameServices: false
    allowEmptyServices: false
  kubernetesCRD:
    enabled: false
  kubernetesGateway:
    enabled: false
service:
  annotations:
    svccontroller.k3s.cattle.io/enablelb: "false"
  spec:
    type: ClusterIP
    externalIPs:
      - <WIREGUARD_SERVER_IP>
ports:
  web:
    port: 8000
    exposedPort: 80
    expose: {default: true}
  websecure:
    port: 8443
    exposedPort: 443
    expose: {default: true}

Follow one private HTTPS request

The user opens https://crm.int.example.com. A resolver override scoped to int.example.com resolves the name to the WireGuard server’s tunnel address. The peer’s routes and AllowedIPs must include that destination. The browser’s HTTPS traffic travels inside WireGuard’s encrypted UDP tunnel to the VM.

WireGuard decrypts the packet on the VM. Its destination is still the tunnel address on port 443. The Service rules direct it to the private Traefik pod on port 8443. Traefik selects the private host rule, terminates TLS using the referenced Secret, and forwards to the configured backend Service endpoints.

In this baseline the backend hop is HTTP on the selected backend port; HTTPS in the browser does not imply TLS between every pod. Application authentication remains an application-level responsibility. WireGuard authenticates peers, while the application decides what its signed-in users may do.

Bind the application route to the private controller

The private Ingress sets spec.ingressClassName to internal and selects the websecure entrypoint. Its tls.secretName points to a certificate Secret in the same namespace. The certificate can be issued through the separate public HTTP-01 path described in the companion article; it does not require a public application route.

There must be no matching application route on the public controller. A wrong class can publish a backend if other policies permit it, so the route manifest itself deserves review. Neither class is default, and the verifier checks that every deployed Ingress has an explicit class.

Configuration excerpt, not a complete deployment. Adapt placeholders and chart version to the environment.

# Private Ingress — excerpt, same namespace as its TLS Secret
metadata:
  annotations:
    traefik.ingress.kubernetes.io/router.entrypoints: websecure
spec:
  ingressClassName: internal
  tls:
    - hosts: [crm.int.example.com]
      secretName: crm-tls
  rules:
    - host: crm.int.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: crm
                port: {number: 80}

Constrain controller traffic in both directions

The private controller namespace starts with default-deny ingress and egress. A narrow ingress rule allows the tunnel peer CIDR on pod ports 8000 and 8443, excluding the server’s own tunnel address. This relies on the source identity observed by the network-policy implementation; NAT behavior must be tested in the selected environment.

Egress allows DNS, the Kubernetes API and the chosen backend namespace and port. Backend-side rules identify both the private controller namespace and its Traefik pods. The shipped validation namespace contains synthetic probes, so its shared test policies are not a substitute for application-specific policies when real workloads are installed. Review all additive policies together.

Verify the boundary from outside

Through WireGuard, confirm the private probe’s identity and a valid certificate. Outside the tunnel, force the same Host header and TLS SNI to each public address. The private probe must never answer there. A generic 404 should come from the public catch-all, not from the private backend. Forged X-Forwarded-For or X-Real-IP headers must not change the result.

Inspect rendered provider arguments, Service addresses, ingress classes, listening ports, firewall behavior and alternate NodePort paths. A disposable unauthorized pod checks the cluster-side boundary. Re-run these checks after controller upgrades, policy changes and recovery. Two controllers on one VM still share a host and cluster: this is traffic separation, not separate-machine isolation.

Read next: certificates for private apps with HTTP-01 or DNS-01 →

Technical references