Kubernetes Deployment Lifecycle on K3s
A walkthrough guide for deploying multi-component applications to a K3s cluster on a VPS. Covers the full lifecycle from containerization to TLS, plus next steps for production hardening.
Disclaimer
I wrote several notes on this topic, including:
Kubernetes
Starting with Minikube
Setting up K3s Cluster
This document seeks to combine all those to a reference for the kdlc.However, some portions of this document, namely the “charts” and manifest templates, were generated by Claude LLM.
Prerequisites
- A VPS with K3s installed and running
- SSH access with an admin account
- UFW configured
kubectlworking against the cluster- Docker installed locally for building images
- A domain name with access to DNS settings
Setting Up a VPS
The Mental Model
Traffic flows through layers, and manifests are written inside-out. Start with the container, work outward to the internet.
Internet
|
Traefik (Ingress Controller, built into K3s but K8s will require one installed and configured)
|
Ingress (routing rules by host/path)
|
Service (stable internal address, ClusterIP)
|
Deployment (manages pods and replicas)
|
Pod
|
Container
Every new app added to the cluster gets its own Deployment and Service. Traefik and the Ingress layer are shared across all apps. External traffic only ever hits ports 80 and 443 on the node. Internal ports (targetPort) are never exposed directly.
Phase 1: Containerize the Applications
Before Kubernetes can schedule anything, each app needs to be a Docker image in a registry.
Write a Dockerfile for each application separately. Then build and push each image to Docker Hub or another registry.
This example uses a Flask server and a Node.js app:
docker build -t yourusername/flask-app:v1 ./flask-app
docker push yourusername/flask-app:v1
docker build -t yourusername/node-app:v1 ./node-app
docker push yourusername/node-app:v1Tag images with a version rather than latest. This makes rollbacks and updates explicit and traceable.
If the images are private, a Kubernetes imagePullSecret will be needed later so the cluster can authenticate with the registry.
Phase 2: Write the Kubernetes Manifests
Create a k8s/ directory in the project root. Each resource gets its own YAML files, and apply them in order.
- Namespaces
- Deployments
- Services
- Ingresses
Namespace
Namespaces isolate an application’s resources from K3s system components and from other apps on the same cluster: kubectl create namespace -n <name>
Deployments
One Deployment per application component. The Deployment manages how many pod replicas run and handles restarts if a pod dies.
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-app
namespace: myapp
spec:
replicas: 1
selector:
matchLabels:
app: flask-app
template:
metadata:
labels:
app: flask-app
spec:
containers:
- name: flask-app
image: yourusername/flask-app:v1
ports:
- containerPort: 5000Mirror this structure for the Node.js frontend, adjusting the name, image, and containerPort accordingly.
The labels field in the pod template and the selector.matchLabels field must match. This is how the Deployment knows which pods it owns.
Services
One Service per application component. A Service gives the pods a stable internal DNS name and IP address inside the cluster. Even if pods are recreated and get new IPs, the Service address stays constant.
apiVersion: v1
kind: Service
metadata:
name: flask-svc
namespace: myapp
spec:
selector:
app: flask-app
ports:
- port: 80
targetPort: 5000The selector must match the labels on the Deployment’s pod template. port is what the Service exposes internally. targetPort is the port the container actually listens on. These do not need to match.
Use type ClusterIP (the default when no type is specified). Services exposed via Ingress should never be NodePort or LoadBalancer, as those bypass the Ingress layer and punch holes directly through the node.
Ingress
The Ingress resource defines the routing rules. Traefik reads these rules and forwards incoming traffic to the correct Service based on host and path.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: myapp-ingress
namespace: myapp
annotations:
kubernetes.io/ingress.class: "traefik"
traefik.ingress.kubernetes.io/router.tls.certresolver: "le-resolver"
spec:
rules:
- host: myapp.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: flask-svc
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: node-svc
port:
number: 80Traffic to /api/* routes to the Flask backend. Everything else routes to the Node.js frontend. Both share the same domain. Traefik handles this routing automatically once the Ingress is applied.
To add more applications later, either add new paths on the same host or add a new host block with its own rules. Traefik picks up changes without any restart.
Phase 3: Apply Manifests to the Cluster
Apply the namespace first, then everything else. Order matters because other resources reference the namespace.
kubectl apply -f k8s/namespace.yaml
kubectl apply -f k8s/flask-deployment.yaml
kubectl apply -f k8s/node-deployment.yaml
kubectl apply -f k8s/flask-service.yaml
kubectl apply -f k8s/node-service.yaml
kubectl apply -f k8s/ingress.yamlThe entire directory can be applied at once after the namespace exists:
kubectl apply -f k8s/apply is idempotent. Running it again on already-applied manifests will update only what changed. This is the normal update workflow.
Phase 4: Verify the Deployment
Run these commands in order to confirm everything is healthy.
# Check that pods are running
kubectl get pods -n myappIf this looks good, skip. Otherwise try the following to determine the issue:
# If a pod is not in Running state, inspect it
kubectl describe pod <pod-name> -n myapp
# Stream application logs
kubectl logs <pod-name> -n myapp
# Confirm services exist and have correct ports
kubectl get svc -n myapp
# Confirm ingress has an address assigned
kubectl get ingress -n myapp
# Inspect ingress routing rules
kubectl describe ingress myapp-ingress -n myappCommon failure causes at this stage:
- Image pull errors: the image name or tag is wrong, or the registry requires authentication
- Port mismatch:
targetPortin the Service does not matchcontainerPortin the Deployment - Label mismatch:
selectorin the Service does not matchlabelsin the pod template - Namespace mismatch: a resource is applied to the wrong namespace
Phase 5: DNS and Going Public
Point the domain at the VPS by adding an A record at the DNS provider:
myapp.example.com → <VPS-IP>
Open ports 80 and 443 in UFW if not already done:
sudo ufw allow 80
sudo ufw allow 443DNS propagation typically takes a few minutes to an hour. Once it resolves, the application is publicly accessible.
Before real DNS is set up, the Ingress can be tested locally by adding an entry to /etc/hosts on the development machine:
<VPS-IP> myapp.example.com
Phase 6: TLS with cert-manager
cert-manager automates TLS certificate issuance and renewal from Let’s Encrypt. Install it:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yamlCreate a ClusterIssuer that points at Let’s Encrypt:
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: le-resolver
spec:
acme:
email: admin@example.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: le-resolver-key
solvers:
- http01:
ingress:
class: traefikThen add a tls block and the cert-manager annotation to the Ingress:
metadata:
annotations:
traefik.ingress.kubernetes.io/router.tls.certresolver: le-resolver
spec:
tls:
- hosts:
- myapp.example.com
secretName: myapp-tls
rules:
...cert-manager will provision the certificate and store it in the named secret. Traefik picks it up automatically. Renewal happens automatically before expiry.
ConfigMaps and Secrets
ConfigMaps
Hardcoded configuration inside a Docker image is inflexible and insecure. ConfigMaps hold NON-SENSITIVE configuration (API URLs, feature flags, port numbers).
Example:
apiVersion: v1
kind: ConfigMap
metadata:
name: flask-config
namespace: myapp
data:
API_URL: "https://api.example.com"
LOG_LEVEL: "info"Save it then apply it!
Secrets
Secrets hold SENSITIVE values (database passwords, API keys, tokens). Both are mounted into pods as environment variables or files.
Process:
- write the
secret.yamlfile then apply. - Mount the secret into the app’s deployment, then apply.
- Check by getting the
Example:
apiVersion: v1
kind: Secret
metadata:
name: flask-secret
namespace: myapp
type: Opaque
stringData:
DATABASE_URL: "postgresql://user:password@host:5432/db"Reference them in the Deployment under env:
env:
- name: API_URL
valueFrom:
configMapKeyRef:
name: flask-config
key: API_URL
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: flask-secret
key: DATABASE_URLLiveness and Readiness Probes
By default, Kubernetes considers a pod healthy as long as the container process is running. Probes give Kubernetes a way to actually verify the application is responding correctly.
A readiness probe tells Kubernetes when a pod is ready to receive traffic. A liveness probe tells Kubernetes when to restart a pod that has stopped responding but is still technically running.
containers:
- name: flask-app
image: yourusername/flask-app:v1
readinessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 5000
initialDelaySeconds: 15
periodSeconds: 20The application needs to expose a health endpoint (e.g. /healthz) that returns HTTP 200 when healthy. This is what makes Kubernetes self-healing actually work properly rather than just detecting process crashes.
Rolling Updates
When application code changes, build a new image with a new tag, update the Deployment manifest, and apply it:
docker build -t yourusername/flask-app:v2 .
docker push yourusername/flask-app:v2Update the image field in the Deployment YAML:
image: yourusername/flask-app:v2Then apply:
kubectl apply -f k8s/flask-deployment.yamlKubernetes performs a rolling update by default: it brings up pods with the new image before terminating pods running the old image, so there is no downtime. Watch the rollout:
kubectl rollout status deployment/flask-app -n myappRoll back if something goes wrong:
kubectl rollout undo deployment/flask-app -n myappPersistent Storage
Containers are ephemeral. Any data written to the container filesystem is lost when the pod restarts. For databases or any stateful workload, a PersistentVolumeClaim (PVC) reserves storage that survives pod restarts.
K3s ships with a local-path provisioner that automatically handles PVC requests using the node’s local disk.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-pvc
namespace: myapp
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5GiMount it in the Deployment:
volumes:
- name: postgres-storage
persistentVolumeClaim:
claimName: postgres-pvc
containers:
- name: postgres
image: postgres:16-alpine
volumeMounts:
- name: postgres-storage
mountPath: /var/lib/postgresql/dataThis is also the point where StatefulSets become relevant. Deployments are for stateless workloads. StatefulSets are for stateful workloads like databases, where pod identity and stable storage per-pod matter.
Port and Traffic Reference
| Layer | Port | Externally Visible |
|---|---|---|
| Traefik entry | 80/443 | Y |
Service port | 80 | N |
Container targetPort | app-specific | N |
Every new application added to the cluster follows the same pattern: its own Deployment, its own Service with its own targetPort, and a new rule in the shared Ingress. The external entry point never changes. |