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
  • kubectl working 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:v1

Tag 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: 5000

Mirror 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: 5000

The 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: 80

Traffic 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.yaml

The 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 myapp

If 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 myapp

Common failure causes at this stage:

  • Image pull errors: the image name or tag is wrong, or the registry requires authentication
  • Port mismatch: targetPort in the Service does not match containerPort in the Deployment
  • Label mismatch: selector in the Service does not match labels in 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 443

DNS 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.yaml

Create 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: traefik

Then 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:

  1. write the secret.yaml file then apply.
  2. Mount the secret into the app’s deployment, then apply.
  3. 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_URL

Liveness 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: 20

The 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:v2

Update the image field in the Deployment YAML:

image: yourusername/flask-app:v2

Then apply:

kubectl apply -f k8s/flask-deployment.yaml

Kubernetes 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 myapp

Roll back if something goes wrong:

kubectl rollout undo deployment/flask-app -n myapp

Persistent 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: 5Gi

Mount 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/data

This 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

LayerPortExternally Visible
Traefik entry80/443Y
Service port80N
Container targetPortapp-specificN
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.

References