Deploy a real app on k3s
The capstone: a complete manifest set — Deployment, Service, Ingress, PVC, ConfigMap, and Secret — that deploys a real self-hosted app on k3s with one kubectl apply, plus where Helm and GitOps take you next.
In this series
Kubernetes from scratch with k3sEight parts in, you have a bag of primitives: pods that self-heal, Deployments that roll forward, Services that give a stable address, Ingress that maps a hostname, and ConfigMaps, Secrets, and PVCs for config and state. Each one you have seen in isolation. A real application is all of them at once, in one directory, applied with one command. This part assembles them into a complete, working manifest set for a self-hosted app — the way you would actually deploy something — and then points at the two tools that take over once hand-writing YAML starts to hurt.
level:Advanced verified:Jun 2026
ℹ️ Hands-on — you'll write these manifests into files and apply them to your own cluster. No cluster yet? Set up a k3s cluster first.
What "a real app" actually requires
Take a typical self-hosted service: a stateful web application with a database password, some non-secret config, and a directory it writes to. Map its needs onto the objects from the previous parts and you get a checklist that is the same for almost everything you will ever deploy:
- a Deployment to run and self-heal the container,
- a Service to give it a stable in-cluster address,
- an Ingress so Traefik routes a hostname to it,
- a PersistentVolumeClaim for the data it must keep,
- a ConfigMap for non-secret settings, and
- a Secret for the password.
These are not independent. They reference each other by name, and that web of references is what turns six YAML documents into one application.
flowchart TD
I["Ingress<br/><i>app.example.com</i>"] -->|"routes to"| S["Service<br/><i>stable ClusterIP</i>"]
S -->|"selects pods of"| D["Deployment<br/><i>1 replica</i>"]
D -->|"creates"| P["Pod<br/><i>the container</i>"]
CM["ConfigMap"] -->|"env"| P
SEC["Secret"] -->|"env"| P
PVC["PVC"] -->|"mounted at /data"| P
Every object points at the next by name. Traffic flows down the left spine — Ingress to Service to pod — while config, secret, and storage attach to the pod from the side.
The manifest set, one file at a time
Make a manifests/ folder and save each block below as its own file. kubectl apply -f manifests/ reads the whole folder in one go, so splitting one object per file buys you nothing technically — it just keeps each piece readable and easy to find later.
manifests/
├── config.yaml # ConfigMap
├── secret.yaml # Secret
├── storage.yaml # PersistentVolumeClaim
├── deployment.yaml # Deployment
├── service.yaml # Service
└── ingress.yaml # Ingress
Note how every name from the diagram appears as a string somewhere below — that string is the wiring.
manifests/config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
APP_ENV: "production"
LOG_LEVEL: "info"
manifests/secret.yaml
# stringData is base64-encoded by k8s, NOT encrypted — keep this out of git
apiVersion: v1
kind: Secret
metadata:
name: app-secret
type: Opaque
stringData:
DB_PASSWORD: "change-me-and-keep-out-of-git"
manifests/storage.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: app-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 5Gi
# no storageClassName -> k3s local-path provisioner
manifests/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: app
spec:
replicas: 1
selector:
matchLabels:
app: app
template:
metadata:
labels:
app: app
spec:
containers:
- name: app
image: my-org/my-app:1.4.0
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: app-config
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: app-secret
key: DB_PASSWORD
volumeMounts:
- name: data
mountPath: /data
readinessProbe:
httpGet:
path: /healthz
port: 8080
volumes:
- name: data
persistentVolumeClaim:
claimName: app-data
manifests/service.yaml
apiVersion: v1
kind: Service
metadata:
name: app
spec:
selector:
app: app
ports:
- port: 80
targetPort: 8080
manifests/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app
port:
number: 80
Notice the chain of name: references: the Service's selector matches the Deployment's pod labels; the Ingress backend names the Service; the Deployment's envFrom names the ConfigMap, its secretKeyRef names the Secret, and its persistentVolumeClaim names the PVC. Nothing is wired by position — everything is wired by name, which is why you can save and apply the files in any order.
⚠️ Warning ThereadinessProbeis not decoration. Without it, the Service will route traffic to the pod the instant the container process starts, before the app is ready to answer — users get errors during every rollout. The probe makes the Service wait until/healthzreturns OK, which is also what lets a rolling update finish with no downtime.
Applying it: one command
Now that the six files are saved in manifests/, apply them all at once. Point kubectl apply at the directory and it sends every manifest to the API server in a single shot:
# Apply every YAML file in the directory
kubectl apply -f ./manifests/
# Watch the objects come up
kubectl get deploy,svc,ingress,pvc,pod
# Follow the app's logs once it's running
kubectl logs -f deploy/app
Within a few seconds you should see all six objects appear, the pod move from ContainerCreating to Running and then Ready once /healthz passes, and the Ingress start serving app.example.com.
kubectl apply manages applications through declarative configuration files: you describe the desired state, and Kubernetes works out the diff against what is running and reconciles. Run it again after editing a file and only the changes are applied — the same command creates, updates, and is safe to repeat.
flowchart LR
A["kubectl apply -f"] -->|"sends desired state"| B["API server"]
B -->|"stores in etcd"| C["Controllers<br/><i>reconcile</i>"]
C -->|"PVC binds, pods scheduled"| D["Pods running"]
D -->|"readiness OK"| E["Service routes<br/><i>Ingress live</i>"]
One command, then the cluster does the work: store the spec, let the controllers create the objects, wait for readiness, and only then send traffic.
💡 Tip Run kubectl apply --dry-run=server -f ./manifests/ before a real apply. It validates the manifests against the live API — catching a misspelled field or a missing reference — without changing anything in the cluster.ℹ️ Note This is exactly how this site runs: a Deployment serving the static build, a Service in front of it, and Traefik Ingress terminating TLS — all on K3s. The manifest set in this post is a simplified cousin of the real one, so the trade-offs here are lived rather than theoretical.
When the YAML starts to hurt: Helm
Six files for one app is fine. Six files times fifteen apps, each with a slightly different image tag, hostname, and replica count, is a copy-paste swamp. This is the problem Helm, the package manager for Kubernetes, exists to solve. Instead of literal YAML, you write templates and a values.yaml:
- A Helm chart is a directory of templated manifests plus a
Chart.yamlof metadata, where the templates are standard YAML with embedded Go templating. - The
values.yamlholds the default configuration that gets rendered into the templates, and you override it with-f myvalues.yamlor--set key=valueper environment.
So the whole manifest set above collapses to one parameterised chart, and deploying becomes a single line:
# Install a packaged app from a chart, overriding a couple of values
helm install my-app ./my-app-chart \
--set image.tag=1.4.0 \
--set ingress.host=app.example.com
The win is that the structure is written once and the differences live in values. Most popular self-hosted software publishes an official chart, so deploying it becomes helm install rather than reverse-engineering six manifests by hand.
💡 Tip Reach for Helm (or Kustomize, which patches plain YAML instead of templating it) the moment you find yourself copy-pasting a manifest and changing two fields. Before that, raw kubectl apply is simpler and easier to read — do not template prematurely.The next horizon: GitOps
There is one more rung. Right now you run kubectl apply, which means the cluster's true state is whatever you last typed — drift creeps in, and "what's actually deployed" diverges from "what's in Git". GitOps closes that loop: a controller in the cluster watches a Git repo and continuously reconciles the cluster toward it.
ℹ️ Note Argo CD is a declarative GitOps continuous-delivery tool implemented as a Kubernetes controller that compares live state against the desired state in a Git repo, and Flux is a set of controllers that continuously pull changes from Git and apply them. Both are CNCF graduated projects. With either, agit pushis the deploy — no one runskubectl applyby hand, and the repo is the single source of truth.
You do not need GitOps for a homelab, and adopting it on day one is overkill. But it is the natural endpoint of the journey this series has been on: from typing commands at a box, to declarative manifests, to those manifests living in version control and the cluster reconciling itself. Each step removed a little more by-hand state.
A short close
A real app is not one big thing — it is the six small objects from the previous parts, wired together by name and applied in a single kubectl apply -f. That is the whole capstone: Deployment, Service, Ingress, PVC, ConfigMap, and Secret, reconciled by the cluster into something running, addressable, and persistent. From there, Helm removes the copy-paste once you repeat yourself, and GitOps removes the by-hand apply once you want Git to be the truth.
This is the end of the line that started with what Kubernetes actually is and ran through Pods, Deployments, Services, Ingress, and config and storage — the series hub holds the whole path. This site lives on exactly this stack on K3s, and if you ever wondered about the name, why Kubernetes is called k8s is the short story.