This is Part 2 of a four-part series where I document how I turned a 10€/month VPS into a production-grade platform hosting multiple projects. In Part 1 we hardened a fresh Ubuntu server. Today we install the actual platform: a container runtime, a lightweight Kubernetes distribution, a modern reverse proxy, and automatic TLS certificates. By the end of this article, deploying an app with HTTPS will take one YAML file and zero manual certificate handling.*
Why Kubernetes on a single VPS?
Fair question. Docker Compose would work, and for a single project I would recommend it without hesitation. But my server hosts several unrelated projects: a portfolio blog, a university group website, and a SaaS platform. I want:
- Isolation: a memory leak in one project must not take down the others.
- Resource quotas: each project gets a defined slice of the 8 GB RAM, enforced by the platform, not by hope.
- Declarative deployments: the entire infrastructure lives in Git as YAML. If the server burns down, I can rebuild it in an hour.
- One ingress to rule them all: a single reverse proxy routes all domains and handles all certificates.
Full Kubernetes (kubeadm) would eat half the server's resources just to run itself. K3s is a certified Kubernetes distribution by SUSE that fits in a ~512 MB memory footprint. Same API, same YAML, a fraction of the overhead. It is the sweet spot for a single-node setup.
What you need
- The hardened Ubuntu VPS from Part 1 (2 vCPU / 8 GB RAM or more recommended)
- A domain name with DNS you control
- 45 minutes
Before starting, create a DNS A record pointing a test subdomain to your server, for example:
whoami.yourdomain.com → YOUR_SERVER_IP
DNS propagation can take a few minutes, so doing it now means it will be ready when we need it in Step 6.
Step 1: Install Docker
K3s uses its own container runtime (containerd), so strictly speaking Docker is not required to run the cluster. We install it anyway, because it is invaluable for building images locally on the server and for quick debugging.
Use the official convenience script:
curl -fsSL https://get.docker.com | sudo sh
Add your user to the docker group so you can run it without sudo:
sudo usermod -aG docker $USER
newgrp docker
Verify:
docker run --rm hello-world
One important detail on a hardened server: Docker manipulates iptables directly and can bypass UFW rules when you publish ports with -p. Since all our public traffic will go through K3s and Traefik, avoid publishing Docker ports on this machine. If you ever need a quick test container, bind it to localhost only: -p 127.0.0.1:8080:80.
Step 2: Install K3s
One command, about thirty seconds:
curl -sfL https://get.k3s.io | sh -s - \
--disable traefik \
--write-kubeconfig-mode 644
Two flags deserve an explanation:
-
--disable traefik: K3s bundles Traefik, but we want to install and control our own Traefik v3 via Helm, with our own configuration. Letting K3s manage it means fighting its defaults later. -
--write-kubeconfig-mode 644: makes the kubeconfig readable by non-root users, convenient on a single-user server. On a shared machine, skip this flag and copy the kubeconfig to your user manually.
Check that the node is ready:
kubectl get nodes
Expected output:
NAME STATUS ROLES AGE VERSION
srv-xxxxx Ready control-plane,master 30s v1.33.x+k3s1
Make kubectl work in every session by pointing it at the K3s kubeconfig:
echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
source ~/.bashrc
Step 3: Install Helm
Helm is the package manager for Kubernetes. We will use it to install Traefik and cert-manager with pinned, reproducible configurations.
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
helm version
Step 4: Install Traefik v3
Traefik is our front door: it receives all HTTP/HTTPS traffic on ports 80 and 443, routes requests to the right application based on the domain name, and terminates TLS.
Add the official Helm repository:
helm repo add traefik https://traefik.github.io/charts
helm repo update
Create a values file so the configuration lives in Git, not in your shell history:
mkdir -p ~/infra/traefik && nano ~/infra/traefik/values.yaml
# ~/infra/traefik/values.yaml
deployment:
kind: Deployment
replicas: 1
service:
type: LoadBalancer # K3s ships with ServiceLB (Klipper), this binds ports 80/443 on the node
ports:
web:
port: 8000
exposedPort: 80
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
port: 8443
exposedPort: 443
tls:
enabled: true
providers:
kubernetesCRD:
enabled: true
kubernetesIngress:
enabled: true
logs:
general:
level: INFO
access:
enabled: true
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 256Mi
The redirections block is doing quiet but important work: every HTTP request is permanently redirected to HTTPS at the entrypoint level, so no application ever needs to think about it.
Install into its own namespace:
kubectl create namespace traefik
helm install traefik traefik/traefik \
--namespace traefik \
--values ~/infra/traefik/values.yaml
Verify the pod is running and the ports are bound:
kubectl get pods -n traefik
kubectl get svc -n traefik
The service should show EXTERNAL-IP equal to your server's IP, with ports 80 and 443 mapped. At this point, visiting http://YOUR_SERVER_IP returns a 404 from Traefik — which is perfect: the front door is open, there is just nothing behind it yet.
Step 5: Install cert-manager
cert-manager automates the entire Let's Encrypt lifecycle: it requests certificates, answers the ACME challenges, stores the certs as Kubernetes secrets and renews them before expiry. Set it up once, never think about TLS again.
helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--set crds.enabled=true
Wait for the three pods to be ready:
kubectl get pods -n cert-manager
Now define two ClusterIssuers: one for Let's Encrypt staging, one for production. Always test with staging first, because the production endpoint rate-limits you to 5 failed attempts per hour, and debugging DNS issues while rate-limited is a special kind of misery.
mkdir -p ~/infra/cert-manager && nano ~/infra/cert-manager/cluster-issuers.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: you@yourdomain.com
privateKeySecretRef:
name: letsencrypt-staging-key
solvers:
- http01:
ingress:
class: traefik
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
email: you@yourdomain.com
privateKeySecretRef:
name: letsencrypt-prod-key
solvers:
- http01:
ingress:
class: traefik
Replace the email (Let's Encrypt uses it for expiry warnings) and apply:
kubectl apply -f ~/infra/cert-manager/cluster-issuers.yaml
kubectl get clusterissuers
Both issuers should show READY: True after a few seconds.
Step 6: Deploy a test app with automatic HTTPS
Time for the payoff. We deploy whoami, a tiny web server that echoes request information, and expose it over HTTPS on the subdomain you configured earlier.
mkdir -p ~/infra/apps && nano ~/infra/apps/whoami.yaml
apiVersion: v1
kind: Namespace
metadata:
name: ns-test
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
namespace: ns-test
spec:
replicas: 1
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami:latest
ports:
- containerPort: 80
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
---
apiVersion: v1
kind: Service
metadata:
name: whoami-svc
namespace: ns-test
spec:
selector:
app: whoami
ports:
- port: 80
targetPort: 80
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: whoami-cert
namespace: ns-test
spec:
secretName: whoami-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- whoami.yourdomain.com
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: whoami-route
namespace: ns-test
spec:
entryPoints:
- websecure
routes:
- match: Host(`whoami.yourdomain.com`)
kind: Rule
services:
- name: whoami-svc
port: 80
tls:
secretName: whoami-tls
Replace whoami.yourdomain.com with your actual subdomain (twice), then:
kubectl apply -f ~/infra/apps/whoami.yaml
Watch the certificate being issued:
kubectl get certificate -n ns-test -w
Within a minute, READY flips to True. Now open https://whoami.yourdomain.com in your browser: a valid Let's Encrypt certificate, automatic HTTP→HTTPS redirect, and the whoami output showing your request went through Traefik.
Take a second to appreciate what just happened: you wrote one YAML file, and the platform handled deployment, routing, certificate issuance and TLS termination. Every future application follows the exact same pattern.
If the certificate stays stuck in READY: False, the usual suspects are:
# Check the ACME challenge status
kubectl describe certificaterequest -n ns-test
kubectl get challenges -n ns-test
Nine times out of ten it is DNS: the A record has not propagated yet, or points to the wrong IP. Verify with dig whoami.yourdomain.com +short.
Final checklist
# 1. K3s node ready
kubectl get nodes
# Expected: STATUS Ready
# 2. Traefik running and bound to 80/443
kubectl get svc -n traefik
# Expected: EXTERNAL-IP = your server IP, ports 80/443
# 3. ClusterIssuers ready
kubectl get clusterissuers
# Expected: both READY True
# 4. Test app serving valid HTTPS
curl -sI https://whoami.yourdomain.com | head -1
# Expected: HTTP/2 200
# 5. HTTP redirects to HTTPS
curl -sI http://whoami.yourdomain.com | grep -i location
# Expected: location: https://whoami.yourdomain.com/
Once everything passes, clean up the test app if you like:
kubectl delete -f ~/infra/apps/whoami.yaml
What's next
We now have a hardened server running a Kubernetes platform with automatic HTTPS. In Part 3, we make it truly multi-tenant: one namespace per project with enforced resource quotas, a shared PostgreSQL and Redis for the small projects, and Traefik middlewares for security headers, rate limiting and CORS. That is where the "several unrelated projects on one 8 GB server without them stepping on each other" promise gets fulfilled.
Questions or suggestions? Reach out, I answer everything. If your certificate refuses to issue and you have already sacrificed a rubber duck to the DNS gods, send me the output of kubectl get challenges and we will figure it out.
Top comments (0)