kubernetes-k3s

SKILL.md

Kubernetes K3s Patterns

Deploy and manage applications on lightweight K3s clusters optimized for single-node VPS deployments with Traefik ingress and cert-manager.

When to Use This Skill

  • Installing and configuring K3s on a VPS
  • Setting up Traefik IngressRoute for subdomain routing
  • Configuring cert-manager with Let's Encrypt for TLS
  • Creating Kustomize base + overlay configurations
  • Managing resource limits on constrained hardware (4GB RAM)
  • Deploying containerized apps to K3s

K3s Installation and Configuration

Single-Node Installation

# Install K3s with default Traefik and HostPort binding
curl -sfL https://get.k3s.io | sh -s - \
  --write-kubeconfig-mode 644

# Verify installation
sudo k3s kubectl get nodes
sudo k3s kubectl get pods -A

# Copy kubeconfig for local kubectl
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config

Kubeconfig for Remote Access

# On local machine: copy kubeconfig and update server address
scp user@server:/etc/rancher/k3s/k3s.yaml ~/.kube/k3s-config
sed -i 's/127.0.0.1/YOUR_SERVER_IP/' ~/.kube/k3s-config
export KUBECONFIG=~/.kube/k3s-config

Embedded Components

K3s ships with:

  • Traefik - Ingress controller (v2.x)
  • CoreDNS - Cluster DNS
  • Flannel - CNI networking
  • Local Path Provisioner - Default storage class
  • Metrics Server - Resource metrics

K3s Upgrade

# Upgrade K3s
curl -sfL https://get.k3s.io | sh -

# Or use system-upgrade-controller for automated upgrades
kubectl apply -f https://github.com/rancher/system-upgrade-controller/releases/latest/download/system-upgrade-controller.yaml

Traefik Ingress

HostPort Configuration (No Load Balancer)

Avoid Hetzner Load Balancer costs (~10€/mo) by using HostPort:

# traefik-config.yaml (HelmChartConfig)
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    ports:
      web:
        hostPort: 80
      websecure:
        hostPort: 443
    # Enable access logs
    logs:
      access:
        enabled: true
    # Dashboard (disable in production)
    ingressRoute:
      dashboard:
        enabled: false

IngressRoute CRDs

# Basic IngressRoute
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: easyfactu-api
  namespace: easyfactu
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`api.easyfactu.es`)
      kind: Rule
      services:
        - name: easyfactu-api
          port: 8000
  tls:
    certResolver: letsencrypt

Wildcard Subdomain Routing

# Route all *.app.easyfactu.es to the frontend
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: easyfactu-web-wildcard
  namespace: easyfactu
spec:
  entryPoints:
    - websecure
  routes:
    - match: HostRegexp(`{subdomain:[a-z]+}.app.easyfactu.es`)
      kind: Rule
      services:
        - name: easyfactu-web
          port: 3000
  tls:
    certResolver: letsencrypt
    domains:
      - main: "app.easyfactu.es"
        sans:
          - "*.app.easyfactu.es"

Middleware

# Rate limiting middleware
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: rate-limit
  namespace: easyfactu
spec:
  rateLimit:
    average: 100
    burst: 50
    period: 1m

---
# HTTPS redirect middleware
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: https-redirect
  namespace: easyfactu
spec:
  redirectScheme:
    scheme: https
    permanent: true

---
# Security headers middleware
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: security-headers
  namespace: easyfactu
spec:
  headers:
    frameDeny: true
    contentTypeNosniff: true
    browserXssFilter: true
    stsSeconds: 31536000
    stsIncludeSubdomains: true

---
# Apply middleware to IngressRoute
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: easyfactu-api
  namespace: easyfactu
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`api.easyfactu.es`)
      kind: Rule
      middlewares:
        - name: rate-limit
        - name: security-headers
      services:
        - name: easyfactu-api
          port: 8000
  tls:
    certResolver: letsencrypt

Cert-Manager

Installation

# Install cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml

# Verify
kubectl get pods -n cert-manager

ClusterIssuer with Let's Encrypt

# Production issuer
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@easyfactu.es
    privateKeySecretRef:
      name: letsencrypt-prod
    solvers:
      - http01:
          ingress:
            class: traefik

---
# Staging issuer (for testing, avoids rate limits)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: admin@easyfactu.es
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
      - http01:
          ingress:
            class: traefik

Certificate Resource

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: easyfactu-tls
  namespace: easyfactu
spec:
  secretName: easyfactu-tls-secret
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - api.easyfactu.es
    - app.easyfactu.es
    - "*.app.easyfactu.es"

Kustomize Patterns

Base Configuration

infra/k8s/
├── base/
│   ├── kustomization.yaml
│   ├── namespace.yaml
│   ├── deployment.yaml
│   ├── service.yaml
│   └── ingress.yaml
└── overlays/
    ├── dev/
    │   ├── kustomization.yaml
    │   └── patches/
    │       └── resource-limits.yaml
    ├── staging/
    │   ├── kustomization.yaml
    │   └── patches/
    └── prod/
        ├── kustomization.yaml
        └── patches/
            ├── resource-limits.yaml
            └── replicas.yaml

Base Kustomization

# infra/k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - namespace.yaml
  - deployment.yaml
  - service.yaml
  - ingress.yaml

commonLabels:
  app.kubernetes.io/managed-by: kustomize

Base Deployment

# infra/k8s/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: easyfactu-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: easyfactu-api
  template:
    metadata:
      labels:
        app: easyfactu-api
    spec:
      containers:
        - name: api
          image: ghcr.io/franciscosanchezn/easyfactu-api:latest
          ports:
            - containerPort: 8000
          envFrom:
            - secretRef:
                name: easyfactu-api-secrets
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:
            httpGet:
              path: /health
              port: 8000
            initialDelaySeconds: 5
            periodSeconds: 10
      imagePullSecrets:
        - name: ghcr-credentials

Production Overlay

# infra/k8s/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

namespace: easyfactu

images:
  - name: ghcr.io/franciscosanchezn/easyfactu-api
    newTag: v1.2.3  # Pin to specific version

patches:
  - path: patches/resource-limits.yaml
# infra/k8s/overlays/prod/patches/resource-limits.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: easyfactu-api
spec:
  template:
    spec:
      containers:
        - name: api
          resources:
            requests:
              memory: "256Mi"
              cpu: "200m"
            limits:
              memory: "512Mi"
              cpu: "1000m"

Dev Overlay

# infra/k8s/overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - ../../base

namespace: easyfactu-dev

namePrefix: dev-

configMapGenerator:
  - name: easyfactu-api-config
    literals:
      - DEBUG=true
      - LOG_LEVEL=debug

secretGenerator:
  - name: easyfactu-api-secrets
    envs:
      - .env.dev

Deploy with Kustomize

# Preview the generated manifests
kubectl kustomize infra/k8s/overlays/prod

# Apply to cluster
kubectl apply -k infra/k8s/overlays/prod

# Dry-run validation
kubectl apply -k infra/k8s/overlays/prod --dry-run=client
kubectl apply -k infra/k8s/overlays/prod --dry-run=server

Resource Management (4GB RAM VPS)

Resource Budget

For a Hetzner CX22 (2 vCPU, 4GB RAM):

Component Memory Request Memory Limit CPU Request CPU Limit
K3s system ~600 MB - - -
Traefik 64 MB 128 MB 50m 200m
cert-manager 64 MB 128 MB 50m 200m
CoreDNS 32 MB 64 MB 50m 100m
App 1 (API) 128 MB 256 MB 100m 500m
App 2 (Web) 64 MB 128 MB 50m 200m
Available ~2.5 GB - - -

Namespace Organization

# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: easyfactu
  labels:
    project: easyfactu
    environment: prod
---
apiVersion: v1
kind: ResourceQuota
metadata:
  name: easyfactu-quota
  namespace: easyfactu
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "2"
    limits.memory: 2Gi
    pods: "10"

Deployment Patterns

Rolling Update

apiVersion: apps/v1
kind: Deployment
metadata:
  name: easyfactu-api
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      containers:
        - name: api
          image: ghcr.io/franciscosanchezn/easyfactu-api:v1.2.3

Container Registry Authentication (GHCR)

# Create GHCR pull secret
kubectl create secret docker-registry ghcr-credentials \
  --namespace=easyfactu \
  --docker-server=ghcr.io \
  --docker-username=franciscosanchezn \
  --docker-password=$GITHUB_TOKEN

Health Checks

containers:
  - name: api
    livenessProbe:
      httpGet:
        path: /health
        port: 8000
      initialDelaySeconds: 10
      periodSeconds: 30
      failureThreshold: 3
    readinessProbe:
      httpGet:
        path: /health
        port: 8000
      initialDelaySeconds: 5
      periodSeconds: 10
      failureThreshold: 3
    startupProbe:
      httpGet:
        path: /health
        port: 8000
      failureThreshold: 30
      periodSeconds: 2

Troubleshooting

Common Commands

# Check pod status
kubectl get pods -n easyfactu -o wide

# View pod logs
kubectl logs -n easyfactu deployment/easyfactu-api -f

# Describe pod for events
kubectl describe pod -n easyfactu <pod-name>

# Check resource usage
kubectl top pods -n easyfactu
kubectl top nodes

# Check Traefik logs
kubectl logs -n kube-system -l app.kubernetes.io/name=traefik -f

# Check cert-manager
kubectl get certificates -A
kubectl describe certificate -n easyfactu easyfactu-tls

# K3s system logs
journalctl -u k3s -f

Common Issues

Issue Cause Fix
Pod stuck in Pending Insufficient resources Check resource quotas, reduce requests
Pod CrashLoopBackOff Application error Check kubectl logs, verify env vars
Certificate not issued DNS not pointing to server Verify DNS A record, check cert-manager logs
503 from Traefik Service not ready Check readiness probe, verify service selector

Dockerfile Best Practices

# Multi-stage build for Python FastAPI
FROM python:3.12-slim AS builder

WORKDIR /app

# Install UV
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

# Copy dependency files
COPY pyproject.toml uv.lock ./

# Install dependencies
RUN uv sync --frozen --no-dev

# Production stage
FROM python:3.12-slim AS runtime

WORKDIR /app

# Copy installed dependencies
COPY --from=builder /app/.venv /app/.venv

# Copy application code
COPY src/ ./src/

# Set PATH to use venv
ENV PATH="/app/.venv/bin:$PATH"

# Run as non-root
RUN useradd --create-home appuser
USER appuser

EXPOSE 8000

CMD ["uvicorn", "easyfactu_api.main:app", "--host", "0.0.0.0", "--port", "8000"]

Cost Reference

Resource Cost Notes
Hetzner CX22 ~5€/mo 2 vCPU, 4GB RAM, 40GB SSD
Supabase Free $0 500MB DB, 50K MAU auth
GHCR $0 Free with GitHub
Let's Encrypt $0 Free SSL certificates
Hetzner DNS $0 Free with Hetzner account
Total ~5€/mo

When Costs May Increase

  • Adding a second VPS node (~5€/mo each)
  • Upgrading to Supabase Pro ($25/mo) for more storage
  • Adding Hetzner Load Balancer (~10€/mo) — avoid with HostPort
  • Adding block storage for persistent volumes (~0.05€/GB/mo)

Operational Commands

# Terraform
cd infra/terraform/environments/prod
terraform init
terraform plan -out=tfplan
terraform apply tfplan

# K3s / Kubernetes
sudo k3s kubectl get pods -A              # All pods
sudo k3s kubectl logs -f deploy/api -n ns # Follow logs
sudo k3s kubectl apply -k infra/k8s/overlays/prod  # Apply Kustomize
sudo k3s kubectl rollout restart deploy/api -n ns   # Restart

# Docker / GHCR
docker build -t ghcr.io/franciscosanchezn/app:tag .
docker push ghcr.io/franciscosanchezn/app:tag

# K9s (terminal UI)
k9s --kubeconfig /etc/rancher/k3s/k3s.yaml

Guidelines

  • Use HostPort for Traefik to avoid Load Balancer costs
  • Set resource requests and limits on all containers
  • Use Kustomize overlays for environment-specific configs
  • Pin image tags in production (never use latest)
  • Configure health checks on all deployments
  • Use namespaces for project isolation
  • Use letsencrypt-staging for testing to avoid rate limits
  • Monitor resource usage to right-size the VPS
  • Keep K3s updated for security patches
  • Use multi-stage Docker builds for small images
  • Target ~5€/mo total infrastructure cost
Weekly Installs
1
First Seen
14 days ago
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1