Last Updated on June 24, 2026 by Thiago Crepaldi
Today, we are taking our private, enterprise-grade Kubernetes cluster and safely piercing the veil to the public internet. Building on our previous guides, we will deploy a standard Nginx-based “Hello World” application and configure our infrastructure so it is reachable at hello.mydomain.com with valid, public-trusted SSL certificates—all while keeping your cluster security intact.
By the end of this tutorial, you will have successfully routed traffic from the global internet, through Cloudflare, through your pfSense firewall, into your bare-metal K3s LoadBalancer (MetalLB), and finally to a containerized pod.
1. The Traffic Flow Architecture
To expose your cluster securely, we rely on a three-tier handshake:
- Cloudflare (DNS Layer): Acts as your authoritative DNS. It will map the subdomain
hello.mydomain.comto your home WAN IP. - pfSense (Perimeter Firewall): Acts as your gatekeeper. It performs Network Address Translation (DNAT/Port Forwarding) to push inbound HTTPS traffic from your public IP to your internal K3s LoadBalancer IP.
- K3s & NGINX Ingress (Orchestration Layer): MetalLB catches the traffic on the internal Virtual IP (
192.168.1.201), hands it to NGINX, which decrypts the TLS (using the certificates we provisioned via Cert-Manager), and routes the request to your specific “Hello World” pod.
2. Configure pfSense (The Gatekeeper)
By default, pfSense drops all unsolicited inbound traffic. We need to explicitly tell it to take traffic hitting port 443 (HTTPS) on your public IP and forward it to your MetalLB IP.
In pfSense, this is a two-step process: Translation (NAT) and Allowance (Firewall Rule). Fortunately, pfSense can do both at once if configured correctly.
Step 2.1: Create the Port Forward (NAT)
- Log into your pfSense WebGUI.
- Navigate to Firewall -> NAT -> Port Forward.
- Click Add (to add a rule to the top or bottom of the list).
- Configure the rule exactly as follows:
- Interface:
WAN - Address Family:
IPv4 - Protocol:
TCP - Destination:
WAN Address - Destination Port Range: From
HTTPS (443)toHTTPS (443) - Redirect Target IP:
192.168.1.201(Replace with your specific MetalLB Ingress IP) - Redirect Target Port:
HTTPS (443) - Description:
K3s NGINX Ingress - HTTPS - Filter rule association: Select Add associated filter rule (This is crucial!).
- Interface:
- Click Save and then Apply Changes.
Step 2.2: Verify the Firewall Rule
Because pfSense evaluates NAT before Firewall rules, the destination IP in the firewall rule must be the internal, post-translated IP. Let’s verify pfSense generated this correctly.
- Navigate to Firewall -> Rules -> WAN.
- Look for the rule created by the NAT process (it will usually have a linked icon next to it).
- Ensure the rule looks exactly like this:
- Action:
Pass(Green checkmark) - Protocol:
IPv4 TCP - Source:
*(Any) - Port:
* - Destination:
192.168.1.201(Your MetalLB IP) - Port:
443 (HTTPS) - Gateway:
*
- Action:
- If this rule is missing, click Add and create it manually using the parameters above. Click Apply Changes.
3. Configure Cloudflare DNS
Now that the gate is open, we need to point the domain to your house.
- Log into your Cloudflare Dashboard and select your
mydomain.comdomain. - Go to DNS -> Records.
- Click Add Record:
- Type:
A - Name:
hello - IPv4 Address: Your Home WAN Public IP (You can find this by googling “What is my IP” from your home network).
- Proxy Status:DNS Only (Grey Cloud).
- Geek Note: We are using Cert-Manager with DNS-01 challenges, so the proxy status technically doesn’t impact certificate generation. However, setting it to “DNS Only” initially removes Cloudflare’s proxy from the equation, making it vastly easier to troubleshoot your pfSense routing. You can turn the proxy on (Orange Cloud) after everything is working!
- Type:
- Click Save.
4. The Kubernetes Manifest
We will define three Kubernetes resources in a single file: a Deployment (the actual app), a Service (internal cluster networking), and an Ingress (the external routing and SSL request).
On your management machine, create a file named hello-world.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: hello-world-deployment
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: hello-world
template:
metadata:
labels:
app: hello-world
spec:
containers:
- name: hello-world
image: nginxdemos/hello:plain-text
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: hello-world-service
namespace: default
spec:
selector:
app: hello-world
ports:
- protocol: TCP
port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-world-ingress
namespace: default
annotations:
# This annotation tells Cert-Manager to automatically provision an SSL cert!
cert-manager.io/cluster-issuer: "letsencrypt-prod"
kubernetes.io/ingress.class: "nginx"
spec:
tls:
- hosts:
- hello.mydomain.com
secretName: hello-world-tls
rules:
- host: hello.mydomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello-world-service
port:
number: 805. Deploy and Validate
Apply the manifest to your cluster:
kubectl apply -f hello-world.yamlVerification Steps
- Check Certificate Issuance: Let’s Encrypt can take anywhere from 30 seconds to 3 minutes to verify the DNS challenge via Cloudflare. Watch the status:
kubectl get certificate hello-world-tls -wWait until theREADYcolumn changes toTrue. PressCtrl+Cto exit. - Check Ingress Status:
kubectl get ingress hello-world-ingressEnsure theADDRESSfield populates with your MetalLB IP (192.168.1.201). - The Public Test: Turn off Wi-Fi on your smartphone (to ensure you are testing from an external network and bypassing potential local NAT reflection issues) and navigate to
https://hello.mydomain.com.You should see the Nginx “Hello World” plain-text response, showing your server address and connection details, fully secured by a verified Let’s Encrypt padlock icon.
6. The Lab Medic: Troubleshooting
If things aren’t working, here is how to isolate the problem:
- Connection Timed Out? The traffic isn’t reaching your cluster. From an external network (like a mobile hotspot), run
telnet <Your-Public-WAN-IP> 443. If it times out, your pfSense Port Forward/Firewall rule is incorrect, or your ISP blocks inbound port 443 (common on some residential plans). - “Site Not Secure” Warning? Your Ingress is reachable, but the SSL certificate failed to generate. NGINX is falling back to its default “Fake Certificate”. Check the Cert-Manager logs to see why the Cloudflare API failed:
kubectl logs -n cert-manager -l app.kubernetes.io/name=cert-manager - 502 Bad Gateway? Traffic reached NGINX, but NGINX can’t find your pod. Check if your deployment is running:
kubectl get pods. Ensure theselectorlabels in your Service match the labels in your Deployment.
7. Advanced: Exposing Non-Standard Ports (e.g., Port 3012)
You might find yourself needing to expose a port other than 80 or 443. A common example is Vaultwarden, which historically used port 3012 for WebSocket notifications, or perhaps you are hosting a game server (like Minecraft on 25565).
Ingress resources are strictly for Layer 7 (HTTP/HTTPS) traffic. To expose raw TCP or UDP ports, you bypass the NGINX Ingress entirely and use a Layer 4 LoadBalancer service. MetalLB will assign a dedicated IP address directly to that service.
Step 7.1: Create a LoadBalancer Service
Instead of defining an Ingress, define your service as type: LoadBalancer and specify the port you need:
apiVersion: v1
kind: Service
metadata:
name: vaultwarden-websocket-service
namespace: default
annotations:
# Optional: Force MetalLB to use a specific IP from your pool
metallb.universe.tf/loadBalancerIPs: 192.168.1.202
spec:
type: LoadBalancer
selector:
app: vaultwarden # Must match your app's deployment labels
ports:
- name: websocket
protocol: TCP
port: 3012
targetPort: 3012When you apply this, MetalLB will assign an IP (e.g., 192.168.1.202). You can check this by running kubectl get svc vaultwarden-websocket-service.
Step 7.2: Update pfSense
Just like we did for port 443 in Phase 1, you must tell your firewall to forward inbound traffic for this new port.
Navigate to Firewall -> NAT -> Port Forward and create a new rule:
- Protocol:
TCP(or UDP, depending on your app) - Destination Port Range:
3012to3012 - Redirect Target IP:
192.168.1.202(The IP MetalLB assigned to this specific service) - Redirect Target Port:
3012 - Filter rule association: Add associated filter rule.
Important Note on SSL for Non-HTTP Ports
Because you are bypassing the NGINX Ingress Controller, Cert-Manager cannot automatically terminate SSL for this connection. Traffic on this port will hit your container exactly as it left the client. If the connection needs to be encrypted (like wss:// for secure WebSockets), the application itself (e.g., Vaultwarden) must be configured to handle the SSL certificates natively.
Congratulations! You are now hosting real, secure traffic directly from your own hardware to the public internet. Now that the plumbing is working, you can easily swap out the “Hello World” image for Nextcloud, WordPress, or your own custom applications.
Conclusion
At this point, if you visit https://hello.mydomain.com, you should be welcomed by your brand new website, including Let’s Encrypt SSL certificates automatically generated for you.

