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.
kubectl get nodes
kubectl get storageclass
kubectl -n kube-system get pods | grep -i traefik
dig +short A app.example.comInstall 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.
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 podsCreate 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.
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 letsencryptCreate 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.
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 pvcDeploy 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.
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,pvcPublish 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.
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 15Switch 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.
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.
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/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 14sBack 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.
sudo ls /var/lib/rancher/k3s/storage/
kubectl delete namespace demoOfficial documentation
These upstream references provide more detail on the commands and workflows in this guide.