Self-Hosting Like a Pro, Part 2: Docker, K3s and Traefik v3 with Automatic HTTPS

작성자

카테고리:

← 피드로
DEV Community · adeutou · 2026-07-22 개발(SW)

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Add your user to the docker group so you can run it without sudo:

sudo usermod -aG docker $USER
newgrp docker

Enter fullscreen mode Exit fullscreen mode

Verify:

docker run --rm hello-world

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Expected output:

NAME        STATUS   ROLES                  AGE   VERSION
srv-xxxxx   Ready    control-plane,master   30s   v1.33.x+k3s1

Enter fullscreen mode Exit fullscreen mode

Make kubectl work in every session by pointing it at the K3s kubeconfig:

echo 'export KUBECONFIG=/etc/rancher/k3s/k3s.yaml' >> ~/.bashrc
source ~/.bashrc

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Create a values file so the configuration lives in Git, not in your shell history:

mkdir -p ~/infra/traefik && nano ~/infra/traefik/values.yaml

Enter fullscreen mode Exit fullscreen mode

# ~/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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Verify the pod is running and the ports are bound:

kubectl get pods -n traefik
kubectl get svc -n traefik

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Wait for the three pods to be ready:

kubectl get pods -n cert-manager

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: [email protected]
    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: [email protected]
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:
          ingress:
            class: traefik

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Replace whoami.yourdomain.com with your actual subdomain (twice), then:

kubectl apply -f ~/infra/apps/whoami.yaml

Enter fullscreen mode Exit fullscreen mode

Watch the certificate being issued:

kubectl get certificate -n ns-test -w

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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/

Enter fullscreen mode Exit fullscreen mode

Once everything passes, clean up the test app if you like:

kubectl delete -f ~/infra/apps/whoami.yaml

Enter fullscreen mode Exit fullscreen mode

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다