KubernetesWritten by GLWS20 min readUbuntu 24.04 LTS / AlmaLinux 9
Commands run on your server unless a section says otherwise. Replace example usernames, addresses and paths with your own values. Keep a working session open when changing remote access.

What you will build

A complete small deployment on a single-node K3s cluster: a namespace, a persistent volume claim, a deployment, a service and an Ingress that serves your domain over HTTPS with an automatically renewed certificate. The example app is Nginx serving files from the volume, so you can see persistence working. Replace it with your own image later.

You need K3s (see the previous guide), a domain whose A record points at the server, and ports 80 and 443 open. K3s ships with Traefik as its ingress controller and a local-path storage class.

Both systems
kubectl get nodes
kubectl get storageclass
kubectl -n kube-system get pods | grep -i traefik
dig +short A app.example.com

Install cert-manager

cert-manager obtains and renews certificates inside the cluster. Install it from the release manifest. Look up the current version on the project's releases page and set it in the variable, since versions change often.

Both systems
CM_VERSION=v1.21.2   # replace with the latest release from the cert-manager releases page
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/$CM_VERSION/cert-manager.yaml
kubectl -n cert-manager rollout status deployment/cert-manager-webhook
kubectl -n cert-manager get pods

Create a Let's Encrypt issuer

A ClusterIssuer tells cert-manager how to get certificates. This one uses Let's Encrypt with the HTTP-01 challenge through Traefik. Start with the staging server while testing, because the production server has strict limits on failures, then switch the server line to the production address.

Both systems
cat <<'EOF' | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    # staging while testing; production is https://acme-v02.api.letsencrypt.org/directory
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: [email protected]
    privateKeySecretRef:
      name: letsencrypt-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
EOF
kubectl get clusterissuer letsencrypt

Create the namespace and the storage claim

Put the application in its own namespace. A PersistentVolumeClaim asks for storage; K3s's local-path provisioner satisfies it with a folder on the node. The claim stays Pending until a pod uses it, which is normal for this storage class.

Both systems
kubectl create namespace demo
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: site-data
  namespace: demo
spec:
  accessModes: ["ReadWriteOnce"]
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi
EOF
kubectl -n demo get pvc

Deploy the application and service

The deployment mounts the claim and sets modest resource requests and limits so the pod cannot take all the memory. A readiness probe keeps traffic away until the app answers. The service gives the pods a stable internal name.

The init container creates the first index.html on an empty volume before Nginx starts. Without it, mounting a new volume hides the bundled homepage and the HTTP readiness check fails. It leaves an existing index.html unchanged.

Both systems
cat <<'EOF' | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: site
  namespace: demo
spec:
  replicas: 1
  selector:
    matchLabels: { app: site }
  template:
    metadata:
      labels: { app: site }
    spec:
      initContainers:
        - name: seed-homepage
          image: busybox:1.37
          command:
            - sh
            - -c
            - 'if [ ! -f /data/index.html ]; then printf "Hello from persistent storage\n" > /data/index.html; fi'
          volumeMounts:
            - name: data
              mountPath: /data
      containers:
        - name: nginx
          image: nginx:stable
          ports:
            - containerPort: 80
          volumeMounts:
            - name: data
              mountPath: /usr/share/nginx/html
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 256Mi }
          readinessProbe:
            httpGet: { path: /, port: 80 }
            initialDelaySeconds: 3
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: site-data
---
apiVersion: v1
kind: Service
metadata:
  name: site
  namespace: demo
spec:
  selector: { app: site }
  ports:
    - port: 80
      targetPort: 80
EOF
kubectl -n demo rollout status deployment/site
kubectl -n demo get pods,pvc

Publish it over HTTPS

The Ingress sends your domain to the service. The cert-manager annotation tells it which issuer to use, and the tls section names the secret where the certificate will be stored. cert-manager then creates a Certificate, solves the challenge and fills in the secret.

Both systems (replace the domain)
cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: site
  namespace: demo
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  ingressClassName: traefik
  tls:
    - hosts: [app.example.com]
      secretName: site-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: site
                port:
                  number: 80
EOF
kubectl -n demo get certificate
kubectl -n demo describe certificate site-tls | tail -n 15

Switch to a production certificate

With the staging issuer the certificate works but browsers do not trust it. When the Certificate shows Ready, change the issuer's server to the production address, delete the secret so cert-manager requests a new certificate, and check again.

Both systems
kubectl edit clusterissuer letsencrypt
kubectl -n demo delete secret site-tls
kubectl -n demo get certificate -w
curl -I https://app.example.com/

Prove the data survives

Write a file into the volume, delete the pod, and check that the file is still served when the new pod starts. This shows that the volume, and not the container, holds the data.

After replacing a pod, wait for readiness and allow the ingress controller a moment to update its endpoints. curl --fail reports HTTP failures; the bounded retry below handles the brief transition instead of silently treating an error page as a successful persistence check.

Both systems
kubectl -n demo exec deploy/site -- sh -c 'echo "hello from a volume" > /usr/share/nginx/html/index.html'
curl --fail --show-error --retry 12 --retry-delay 2 --retry-all-errors https://app.example.com/
kubectl -n demo delete pod -l app=site
kubectl -n demo rollout status deployment/site --timeout=180s
kubectl -n demo wait --for=condition=Ready pod -l app=site --timeout=180s
curl --fail --show-error --retry 12 --retry-delay 2 --retry-all-errors https://app.example.com/
RECORDED LAB OUTPUT · Ubuntu 24.04 LTS
persistentvolumeclaim/site-data created
deployment.apps/site created
pod/site-7884f8fb7b-grgsf condition met
pod/site-7884f8fb7b-grgsf   1/1     Running   0          15s
persistentvolumeclaim/site-data   Bound    pvc-06377919-48ba-41c1-9b4d-ec2b0ae4eec8   1Gi        RWO            local-path     <unset>                 16s
certificate.cert-manager.io/site-tls condition met
hello from a volume
pod/site-7884f8fb7b-xzwvd condition met
hello from a volume
hello from a volume
deployment.apps/site   1/1     1            1           33s
pod/site-7884f8fb7b-xzwvd   1/1     Running   0          14s
Recorded lab check after replacing the pod: the volume still served its data. HTTPS used a trusted local test CA; this capture does not demonstrate public Let’s Encrypt issuance.

Back it up and clean up

The local-path volume is a folder on the node, under /var/lib/rancher/k3s/storage. It is lost if the server is lost, so back that folder up with restic, as in the backup guide, and keep your YAML in Git. To remove the demo, delete the namespace, which removes everything inside it, including the claim.

Both systems
sudo ls /var/lib/rancher/k3s/storage/
kubectl delete namespace demo

Official documentation

These upstream references provide more detail on the commands and workflows in this guide.