A comprehensive guide to building a self-hosted homelab architecture from physical bare-metal servers, Proxmox virtualization, Kubernetes (K3s) orchestration, to exposing it securely to the public internet.
Following my previous post on why building a homelab still matters, this time I want to dive deeper into how I built my latest homelab architecture—starting from the physical hardware (bare metal) to exposing services securely to the public internet.
Many people starting out with a homelab immediately deploy everything inside Kubernetes (K8s), or conversely, deploy everything using loose Docker Compose files on separate VMs/LXCs. Here, I chose a middle-ground approach: combining the orchestration power of Kubernetes (K3s) for stateless applications and the native performance of Standalone LXC Containers for stateful services (like databases and storage), all running on a single physical Proxmox VE hypervisor.
At the lowest level (bare metal), I installed Proxmox VE. Why not just install a standard Linux OS (like Ubuntu/Debian) and run Docker directly?
Short answer: Yes, for a personal homelab scale, Kubernetes is absolutely overkill.
Managing a K8s cluster requires extra RAM for the control plane, complex network configuration, and a steep learning curve. In this setup, I am not chasing High Availability (HA) or advanced failover capabilities at all.
There are two primary reasons why I decided to go with Kubernetes anyway:
Dynamic Client Provisioning (Multi-Tenancy): I am building a SaaS/multi-tenant platform where every time a new client registers, the system must automatically:
If I used loose Docker Compose setups, my backend would have to manually SSH into the server, write dynamic compose files, and run shell commands. This is insecure and highly prone to race conditions. With Kubernetes, my backend simply targets the Kube API declaratively: "Please create a new Namespace, create this Deployment, and attach this IngressRoute." Kubernetes handles the rest. K8s is not just a deployment tool; it is the system engine for my infrastructure automation.
Hands-on Learning: Reading Kubernetes documentation is one thing, but managing it directly on bare-metal hardware is a completely different beast. This homelab is my personal playground to practice cloud-native concepts in real life—from handling networking, storage provisioning, and secrets management, to debugging pod failures at the OS level. It is the best way to learn and understand the actual pain points faced by DevOps teams in production, rather than just absorbing theory from a screen.
Despite needing Kubernetes, I avoided the standard K8s distribution (like kubeadm). In a homelab environment where RAM is gold, K3s (by Rancher) is a lifesaver.
Here is a visualization of how traffic flows from the public internet down to the application pods inside the cluster, and how external components are connected:
How does a request from a user's browser reach our application inside the Kube cluster without having to configure port forwarding on our ISP router?
Here is the path:
app.mycloud.dev. This request is first received by the nearest Cloudflare Edge server. Cloudflare handles SSL/TLS termination automatically at their edge.cloudflared)
Inside the Kubernetes cluster, we run a Cloudflare Tunnel (cloudflared) deployment as a Pod. This pod establishes secure outbound connections (tunnels) to Cloudflare Edge. Because the connection is outbound, we do not need to open any inbound ports on our local firewall.cloudflared Pod. This pod then throws the traffic to the internal Service of the Traefik Ingress Controller (acting as the main reverse proxy in the cluster).Ingress or IngressRoute configurations. It matches the routing rule: "If the host is app.mycloud.dev, route the traffic to Service core-backend-service on port 9000".One of the challenges of this architecture is how pods inside Kubernetes can access databases or staging services residing outside the K8s cluster (on standalone LXCs)?
Kubernetes provides a clean native feature to handle this without having to hardcode physical IP addresses inside our application code:
Normally, K8s Services distribute traffic to Pods based on a label selector. However, we can create a Service without a selector:
apiVersion: v1
kind: Service
metadata:
name: db-cluster-svc
namespace: default
spec:
ports:
- protocol: TCP
port: 5432
targetPort: 5432
Because the Service above has no selector, Kube will not create endpoints automatically. We must define the endpoint manually to point to the physical IP of the standalone LXC (e.g., db-cluster at IP 192.168.1.103):
apiVersion: v1
kind: Endpoints
metadata:
name: db-cluster-svc
namespace: default
subsets:
- addresses:
- ip: 192.168.1.103
ports:
- port: 5432
Using this method, our applications inside the Kube cluster can simply connect to the internal host db-cluster-svc.default.svc.cluster.local:5432. If the physical database IP changes in the future, we only need to update this Endpoints configuration file without modifying any application code.
For SaaS requirements or multi-tenant applications, we need to spin up new application instances dynamically when a new client registers. With this architecture, the flow is highly elegant:
core-backend) processes the request.db-host programmatically to create an isolated database and database user for the client:
pod_db_<product_name>_<workspace_id> (e.g., pod_db_service_clientx)pod_user_<product_name>_<workspace_id> (e.g., pod_user_service_clientx)k8s-api.mycloud.dev to provision new resources:
client-pod-<product_name>-<workspace_id> (e.g., client-pod-service-clientx).clientx.mycloud.dev).By isolating each client instance in its own namespace:
ResourceQuota.Separating responsibilities between Kubernetes (for dynamic and stateless applications) and Standalone LXC (for databases and storage) provides incredible stability to the homelab.
We get all the benefits of modern orchestration from Kubernetes (such as auto-healing, easy scaling, and dynamic provisioning via API), without sacrificing the read-write disk performance of databases, which are typically slower if run inside Kubernetes virtual storage.
The entire infrastructure is defined using Terraform for the Proxmox side, ensuring that if a server dies, we can re-provision our entire homelab in minutes!