Back to posts

Building a MicroK8s Sandbox Cluster on Proxmox

Read the full guide on docs.beyondyou.my.id
kubernetesmicrok8sproxmoxsandboxdevopslocal-development

Building a MicroK8s Sandbox Cluster on Proxmox

Table of Contents

SectionTopicDescription
01Why MicroK8s on ProxmoxLightweight Kubernetes for local development.
02Architecture Overview3-node cluster topology on Proxmox VMs.
03Proxmox VM SetupVM configuration for master and worker nodes.
04MicroK8s InstallationSnap-based install and addon enablement.
05Cluster JoinAdding worker nodes to the cluster.
06Deployment HardeningSecurity contexts, affinity, and resource limits.
07Service & AutoscalingClusterIP service and HPA configuration.
08GCP Artifact Registry AccessPulling private images from GCR.
09Cluster Admin Accesskubeconfig setup for remote access.
10TeardownClean removal of MicroK8s.

1. Why MicroK8s on Proxmox

For local development and testing, running a full GKE cluster is overkill. MicroK8s gives us a lightweight, single-binary Kubernetes distribution that runs on commodity VMs.

OptionProsCons
MinikubeSimple, single-nodeNo multi-node, no cluster join
kindDocker-based, fastLimited networking, no MetalLB
k3sLightweight, production-gradeSeparate install from snap
MicroK8sSnap install, addons, multi-nodeSnap dependency

Why Proxmox

ReasonDetail
Self-hostedNo cloud costs for sandbox workloads
SnapshotsRoll back broken experiments instantly
Resource controlCPU/memory limits per VM
Network isolationVLANs for lab environments

2. Architecture Overview

Three VMs on a single Proxmox host — 1 control plane, 2 workers.

graph TB
    subgraph PROXMOX["Proxmox Host"]
        subgraph VM_MASTER["VM: microk8s-master"]
            m_cp["Control Plane\nAPI Server\netcd\nscheduler"]
            m_apps["CoreDNS\nmetrics-server\ndashboard"]
        end
        subgraph VM_WORKER1["VM: microk8s-worker-1"]
            w1_apps["Application Pods\nIngress Controller"]
        end
        subgraph VM_WORKER2["VM: microk8s-worker-2"]
            w2_apps["Application Pods\nIngress Controller"]
        end
    end

    m_cp -->|"cluster join"| w1_apps
    m_cp -->|"cluster join"| w2_apps
    w1_apps -->|"pod scheduling"| m_cp
    w2_apps -->|"pod scheduling"| m_cp

VM Resource Allocation

NodeCPURAMDiskRole
microk8s-master2 vCPU4 GB40 GBControl plane
microk8s-worker-12 vCPU4 GB40 GBWorker
microk8s-worker-22 vCPU4 GB40 GBWorker
Total6 vCPU12 GB120 GB

3. Proxmox VM Setup

VM Configuration (master)

# /etc/pve/qemu-server/100.conf
balloon: 0
boot: order=scsi0
cores: 2
cpu: host
memory: 4096
name: microk8s-master
net0: virtio=AA:BB:CC:DD:EE:01,bridge=vmbr0
scsi0: local-lvm:vm-100-disk-0,size=40G
scsihw: virtio-scsi-pci

VM Configuration (workers)

# /etc/pve/qemu-server/101.conf (worker-1)
# /etc/pve/qemu-server/102.conf (worker-2)
balloon: 0
boot: order=scsi0
cores: 2
cpu: host
memory: 4096
name: microk8s-worker-1
net0: virtio=AA:BB:CC:DD:EE:02,bridge=vmbr0
scsi0: local-lvm:vm-101-disk-0,size=40G
scsihw: virtio-scsi-pci

OS Preparation (all nodes)

# Update and install dependencies
sudo apt-get update -y
sudo apt-get install -y apt-transport-https ca-certificates curl gnupg lsb-release

# Ensure snap is installed
command -v snap >/dev/null 2>&1 || {
  echo 'Snap is not installed. Installing it.'
  sudo apt-get install -y snapd
}

4. MicroK8s Installation

Install on All Nodes

# Install MicroK8s
sudo snap install microk8s --classic

# Wait for node to be ready
sudo microk8s status --wait-ready

Enable Addons (Master Only)

sudo microk8s enable dashboard ingress dns rbac metrics-server cert-manager observability rook-ceph metallb
sudo microk8s start
sudo microk8s status

Addon Summary

AddonPurpose
dashboardKubernetes web UI
ingressNGINX ingress controller
dnsCoreDNS for service discovery
rbacRole-based access control
metrics-serverHPA resource metrics
cert-managerTLS certificate automation
observabilityPrometheus + Grafana stack
rook-cephDistributed storage
metallbLoadBalancer for bare metal

5. Cluster Join

Generate Join Token (Master)

sudo microk8s add-node

Output:

microk8s join 10.4.252.45:25000/<token>/<node-id>

Join as Worker (Worker Nodes)

# On worker-1 and worker-2
microk8s join <master-node-ip>:25000/<token>/<node-id> --worker

The --worker flag ensures the node joins as a worker only — it does not run control plane components.

Verify Cluster

# On master
kubectl get nodes
NAME                 STATUS   ROLES    AGE   VERSION
microk8s-master      Ready    <none>   5m    v1.28.x
microk8s-worker-1    Ready    <none>   2m    v1.28.x
microk8s-worker-2    Ready    <none>   1m    v1.28.x

Network Considerations

If nodes are not reachable through the default interface:

# Specify the interface explicitly
microk8s join <master-ip>:25000/<token>/<node-id> --worker

# Or leave the cluster from a worker
microk8s leave

6. Deployment Hardening

Our deployment manifest includes production-grade security and scheduling hardening.

Full Manifest

apiVersion: apps/v1
kind: Deployment
metadata:
  name: [environment]-[app_name]
  namespace: [namespace]
  labels:
    app: [app_name]
    env: [environment]
    team: [team]
    app.kubernetes.io/name: [app_name]
    app.kubernetes.io/instance: [environment]-[app_name]
    app.kubernetes.io/component: [component]
    app.kubernetes.io/part-of: [team]
    app.kubernetes.io/managed-by: DevOpsTeam
spec:
  replicas: 1
  selector:
    matchLabels:
      app: [app_name]
  template:
    metadata:
      labels:
        app: [app_name]
    spec:
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: pool-type
                    operator: In
                    values:
                      - [node-pool]
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 60
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: [app_name]
                topologyKey: "kubernetes.io/hostname"
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: "kubernetes.io/hostname"
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: [app_name]
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      tolerations:
          - key: "app-type"
            operator: "Equal"
            value: "[app-type]"
            effect: "NoSchedule"
      containers:
        - name: [app_name]
          image: [image]
          imagePullPolicy: IfNotPresent
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            runAsNonRoot: true
            runAsUser: 1000
            runAsGroup: 1000
            capabilities:
              drop:
                - ALL
          ports:
            - containerPort: [port]
              name: http
          resources:
            limits:
              memory: [memory]
            requests:
              cpu: [cpu]
              memory: [memory]
          env:
            - name: APP_ENV
              value: production
          livenessProbe:
            httpGet:
              path: /
              port: [port]
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /
              port: [port]
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 50%
      maxUnavailable: 0

Security Hardening Breakdown

LayerConfigurationPurpose
UserrunAsUser: 1000Non-root execution
FilesystemreadOnlyRootFilesystem: truePrevent runtime writes
Capabilitiesdrop: [ALL]Remove all Linux capabilities
SeccompRuntimeDefaultRestrict syscalls
PrivilegeallowPrivilegeEscalation: falseBlock suid binaries

Scheduling Breakdown

PolicyConfigEffect
Node affinitypool-type: [node-pool]Pin to specific node pool
Pod anti-affinityweight: 60, hostnamePrefer different nodes for same app
Topology spreadmaxSkew: 1Even distribution across nodes
Tolerationsapp-type: [app-type]Schedule on tainted nodes

Rolling Update Strategy

FieldValueWhy
maxSurge: 50%New pods before old are killedZero downtime
maxUnavailable: 0Never reduce below desired countCapacity maintained

7. Service & Autoscaling

ClusterIP Service

apiVersion: v1
kind: Service
metadata:
  name: [environment]-[app_name]-svc
  namespace: [namespace]
  labels:
    app: [app_name]
    env: [environment]
    team: [team]
    app.kubernetes.io/name: [app_name]
    app.kubernetes.io/instance: [environment]-[app_name]
    app.kubernetes.io/component: [component]
    app.kubernetes.io/part-of: [team]
    app.kubernetes.io/managed-by: DevOpsTeam
spec:
  selector:
    app: [app_name]
  type: ClusterIP
  ports:
    - name: http
      port: 80
      targetPort: [port]
      protocol: TCP

Horizontal Pod Autoscaler

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: [environment]-[app_name]-hpa
  namespace: [namespace]
  labels:
    app: [app_name]
    env: [environment]
    team: [team]
    app.kubernetes.io/name: [app_name]
    app.kubernetes.io/instance: [environment]-[app_name]
    app.kubernetes.io/component: [component]
    app.kubernetes.io/part-of: [team]
    app.kubernetes.io/managed-by: DevOpsTeam
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: [environment]-[app_name]
  minReplicas: 2
  maxReplicas: 5
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 80
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 85

HPA Behavior

MetricTargetScale UpScale Down
CPU80% utilizationAdd pods when threshold exceededRemove pods when underutilized
Memory85% utilizationAdd pods when threshold exceededRemove pods when underutilized
Min replicas2Always at least 2 running
Max replicas5Never exceed 5

8. GCP Artifact Registry Access

To pull private images from Google Container Registry into MicroK8s:

Create Service Account

# Create service account
gcloud iam service-accounts create microk8s-dev-cluster \
  --display-name="MicroK8s Image Puller"

# Grant Artifact Registry reader
gcloud projects add-iam-policy-binding YOUR_PROJECT_ID \
  --member="serviceAccount:microk8s-dev-cluster@YOUR_PROJECT_ID.iam.gserviceaccount.com" \
  --role="roles/artifactregistry.reader"

# Create and download key
gcloud iam service-accounts keys create key.json \
  --iam-account=microk8s-dev-cluster@YOUR_PROJECT_ID.iam.gserviceaccount.com

Create Kubernetes Secret

kubectl create secret docker-registry gcr-secret \
  --docker-server=asia.gcr.io \
  --docker-username=_json_key \
  --docker-password="$(cat key.json)" \
  --docker-email=microk8s-dev-cluster@YOUR_PROJECT_ID.iam.gserviceaccount.com \
  -n workload

Reference in Deployment

spec:
  template:
    spec:
      imagePullSecrets:
        - name: gcr-secret

9. Cluster Admin Access

Export kubeconfig

# On the master node
microk8s config > ~/.kube/config

Cluster Admin kubeconfig

apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
    server: https://10.4.252.45:16443
  name: microk8s-dev-cluster
contexts:
- context:
    cluster: microk8s-dev-cluster
    user: microk8s-dev-admin
  name: microk8s-dev-cluster
current-context: microk8s-dev-cluster
kind: Config
preferences: {}
users:
- name: microk8s-dev-admin
  user:
    client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t...
    client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQp...

Copy to Local Machine

# From master
scp microk8s-master:~/.kube/config ~/.kube/microk8s-dev

# Set KUBECONFIG
export KUBECONFIG=~/.kube/microk8s-dev

# Verify
kubectl get nodes

10. Teardown

Remove MicroK8s

sudo snap remove microk8s

Delete VMs (Proxmox)

# Stop and destroy VMs
qm stop 100 && qm destroy 100
qm stop 101 && qm destroy 101
qm stop 102 && qm destroy 102

References