Local Kubernetes Dev — Part 7: Kubernetes manifests for your service
Three manifests for your service — Namespace, Deployment, and Service — and how labels and selectors glue them together into a minimal but working skeleton.
In the previous chapter we packaged myapp into a Docker image. Now you need to tell the cluster what to do with that image: how many copies to run, which port it listens on, and how other parts of the system will find it. You do this through manifests — plain-text YAML files in which you declaratively describe the desired state. You don't command "run this container"; you say "I want the cluster to look like this," and Kubernetes brings reality in line with the description and keeps it there.
In this chapter we'll write three manifests for our running example — a Namespace, a Deployment, and a Service — and look at how they glue together. We'll leave PostgreSQL and other dependencies for the dependencies chapter, configuration and secrets for the configuration and secrets chapter, and external access through Ingress for the networking chapter. Here we build a minimal but working skeleton.
Namespace: isolating your application
A namespace is a mechanism for isolation and name scoping within a single cluster. Roughly speaking, it's a folder for your resources. Object names are unique within a single namespace, but not across namespaces: you can have a Deployment named myapp in namespace dev and another in namespace staging at the same time, with no conflict.
Out of the box a cluster has four system namespaces: default (where everything lands if you don't specify another), kube-system (the system components of Kubernetes itself), kube-public, and kube-node-lease. A good habit is to not dump your application into default and instead create a dedicated namespace. That makes it easier to delete everything at once, attach resource limits, and avoid mixing your stuff with someone else's. The kube- prefix is reserved for system needs, so you can't name your own namespaces that way. And you can't nest namespaces inside one another, by the way — there's no hierarchy here.
It's important to understand that a namespace applies only to namespaced objects: Pod, Deployment, Service, ConfigMap, Secret, and so on. There are also cluster-scoped objects that live at the level of the whole cluster and don't sit in any namespace: Node, PersistentVolume, StorageClass. You can check which category something belongs to like this:
1kubectl api-resources --namespaced=true
2kubectl api-resources --namespaced=falseYou can create a namespace with a single command or with a manifest. Since we've agreed to keep everything in Git (more on that below), we'll use a manifest. The name must be a valid DNS label per RFC 1123 — lowercase letters, digits, and hyphens. Our namespace is called myapp:
1# namespace.yaml
2apiVersion: v1
3kind: Namespace
4metadata:
5 name: myappApply it:
1kubectl apply -f namespace.yamlTo avoid appending -n myapp to every command, it's convenient to switch your context to the right namespace once:
1kubectl config set-context --current --namespace=myappThere's one more thing worth knowing about namespaces in the context of DNS. Services inside the cluster get a domain name of the form <service>.<namespace>.svc.cluster.local. If you reach a service from the same namespace, the short name is enough (myapp). From a different namespace you need the full name (FQDN), for example myapp.myapp.svc.cluster.local. This is a classic trap: "I can't reach it from a neighboring namespace using the short name." There's more on in-cluster DNS in the networking chapter.
Deployment: pod, container, image, replicas
Before writing the Deployment, let's sort out the terms from the bottom up.
- A container is a running instance of your image (the very one we built in the containerization chapter).
- A Pod is the smallest unit Kubernetes works with. It's a "wrapper" around one or more containers that share network and storage. In our case a pod = one container running
myapp. Pods are ephemeral: if one dies, Kubernetes throws the old one away and creates a new one with a new IP. That's why you almost never create pods by hand directly. - A ReplicaSet is a controller that makes sure a given number of identical pods is always running in the cluster.
- A Deployment is what you use in practice. It manages ReplicaSets and provides declarative updates: change the image, and the Deployment smoothly rolls out the new version, rolling back if something goes wrong.
The hierarchy comes out like this: Deployment → ReplicaSet → Pods. You describe only the top level; Kubernetes creates the rest itself (it derives the ReplicaSet name from the Deployment name plus a hash).
Here's the manifest for myapp. The service listens on port 8080 (the HTTP API on FastAPI/uvicorn), and we pull the image from the k3d built-in registry — k3d-registry.localhost:5000/myapp:dev (as configured in the k3d chapter and the containerization chapter):
1# deployment.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: myapp
6 namespace: myapp
7 labels:
8 app: myapp
9spec:
10 replicas: 1
11 selector:
12 matchLabels:
13 app: myapp
14 template:
15 metadata:
16 labels:
17 app: myapp
18 spec:
19 containers:
20 - name: myapp
21 image: k3d-registry.localhost:5000/myapp:dev
22 ports:
23 - containerPort: 8080Let's go over the key fields:
apiVersion: apps/v1andkind: Deployment— the type of object we're describing.spec.replicas: 1— how many copies of the pod to keep. The default is 1, which is usually what you want for local development. In production you set more for fault tolerance.spec.selector.matchLabels— the labels by which the Deployment recognizes "its own" pods.spec.template— the template from which pods are stamped out: their labels (metadata.labels) and containers (name,image,ports.containerPort).
The single most important rule that trips up every beginner: spec.selector.matchLabels must match spec.template.metadata.labels. If they diverge, Kubernetes rejects the manifest right at apply time. The logic is simple: the Deployment creates pods with the labels from the template, then uses the selector to find what it created. If it looks for something other than what it creates, the system can't converge. We use app: myapp everywhere, so we're fine.
We made up the app: myapp label ourselves — it's an arbitrary key/value pair. Real projects often add Kubernetes's recommended labels as well, so that tools (Helm, dashboards, monitoring) understand your objects in a uniform way: app.kubernetes.io/name, app.kubernetes.io/instance, app.kubernetes.io/version, app.kubernetes.io/component, app.kubernetes.io/part-of, app.kubernetes.io/managed-by (Recommended Labels). They're optional, and for a first introduction a simple app: myapp is enough — but it's worth knowing they exist.
We deliberately gave a minimal Deployment here. In a real local setup you'd add environment variables, readiness checks (readinessProbe/livenessProbe), and resource limits — we'll cover those in the chapter on getting closer to prod, when we make the service more production-like.
Service (ClusterIP): a stable address inside the cluster
Pods are ephemeral and constantly change their IP — talking to them directly makes no sense. To give a group of pods one stable address, there's the Service object. A Service is an abstraction: "here's a set of pods, reach them through me by a single name, and I'll figure out where to forward the request."
The default Service type is ClusterIP. This is a virtual IP address reachable only inside the cluster. The Service gets not just this stable IP but also a DNS name, so clients don't hardcode addresses at all — they simply go to the name myapp. The controller constantly watches which pods match the selector and updates the list of their addresses (internally this is called EndpointSlices).
The Service manifest for myapp:
1# service.yaml
2apiVersion: v1
3kind: Service
4metadata:
5 name: myapp
6 namespace: myapp
7spec:
8 selector:
9 app: myapp
10 ports:
11 - protocol: TCP
12 port: 80
13 targetPort: 8080The two port-related fields often get confused — remember the difference:
port— the port the Service itself listens on. This is where other clients in the cluster connect. Here we've set it to 80.targetPort— the port on the pod where the Service forwards traffic. Themyappapplication listens on 8080, sotargetPort: 8080.
If you don't specify targetPort, it defaults to port. We specified it explicitly because ours differ. Now any pod in the cluster can hit http://myapp.myapp.svc.cluster.local:80 (or just http://myapp from the same namespace) and reach myapp on port 8080.
Remember: "from the outside" (other services in the cluster, an Ingress) you reach myapp on the Service's port 80, not 8080 — the application listens on 8080 only inside the pod. The Service translates one to the other. When you later run into kubectl port-forward, pay attention to a notation like 8080:80 — the left side is your local port, the right side is the Service port (80); you can also forward straight to the pod's port (8080:8080), bypassing the Service. Both are valid; they just target different ports, which is why in the Tilt chapter and the observability chapter the numbers look different.
ClusterIP makes the service reachable only from inside. To reach myapp from a browser on your machine, you need kubectl port-forward or an Ingress — that's the topic of the networking chapter.
How Deployment and Service connect through labels/selectors, in plain terms
Now for the thing you really need to feel. We have three objects (Deployment, pods, Service), and nothing connects them with hard references like "Service, here are the IDs of these pods." Instead, everything rests on labels — arbitrary tags on objects. Labels are the glue here.
Let's trace the chain in our example with the app: myapp label:
- The Deployment uses
spec.template.metadata.labelsto attach theapp: myapplabel to every pod it creates. - The same Deployment uses
spec.selector.matchLabels: {app: myapp}to recognize those pods as "its own" — behind this is a ReplicaSet that counts the pods matching this selector and keeps their number in line. - The Service uses its own
spec.selector: {app: myapp}to find pods with the same label, collects their addresses into its EndpointSlice, and balances traffic across them.
So two different selectors — the Deployment's and the Service's — look at the same pod labels but solve different problems: the Deployment answers "which pods are mine, for counting replicas," and the Service answers "where to send traffic."
A small syntax wrinkle that throws people off: the Service writes its selector directly, flat — selector: {app: myapp} (this is what's called an equality-based selector). The Deployment/ReplicaSet, on the other hand, uses selector.matchLabels (the newer format, which can also do matchExpressions for trickier set-based conditions). Don't let it scare you: these are just different generations of syntax, and both compare labels.
The most common trap is right here: the Service's selector doesn't match the pods' labels (a typo, or you forgot to update it). Then the Service binds to nothing, and in its description the Endpoints field will be empty — <none>. On the surface the service exists, but traffic goes nowhere. You can check this instantly:
1kubectl describe svc myapp -n myappIf the output shows Endpoints: <none>, the pod's label and the Service's selector didn't match. Compare the app: values in both manifests.
kubectl apply -f / get / describe
The manifests are written — time to apply them to the cluster and learn how to see what's going on. The main tool is kubectl apply. It's a declarative command: "bring the cluster in line with what's in this file." It's idempotent — you can run it as many times as you like: the first run creates the resource, subsequent runs apply only the changes (via what's called a three-way merge).
1# one file at a time
2kubectl apply -f namespace.yaml
3kubectl apply -f deployment.yaml -f service.yaml
4
5# or a whole folder of manifests at once
6kubectl apply -f ./k8s/Sometimes it's convenient to keep a Deployment and Service in one file — Kubernetes understands multiple objects in a single YAML if you separate them with a --- line:
1# myapp.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: myapp
6 namespace: myapp
7# ... Deployment spec ...
8---
9apiVersion: v1
10kind: Service
11metadata:
12 name: myapp
13 namespace: myapp
14# ... Service spec ...You may have seen kubectl create somewhere. Remember the difference: create is an imperative command — it fails with an error if the resource already exists, and it can't update. For repeatable manifests stored in Git, always use apply.
Before applying changes, it's useful to see exactly what will change:
1kubectl diff -f deployment.yamlNext, the commands for inspecting state. kubectl get shows lists of resources:
1kubectl get pods -n myapp # pods in our namespace
2kubectl get all -n myapp # everything at once: pods, deployments, services, replicasets
3kubectl get pods -o wide # + pod IPs and the node they run on
4kubectl get pod <NAME> -o yaml # the full YAML of a specific object
5kubectl get pods -w # watch changes in real time
6kubectl get pods --show-labels # show labels
7kubectl get pods -l app=myapp # filter by label
8kubectl get pods -A # across all namespaces at onceBy the way, about -n myapp: if you forget this flag (and haven't switched context, as above), kubectl looks in default, and it'll seem like there are no resources. A very common cause of the "where did everything go?" panic.
When something goes wrong, the main diagnostic tool is kubectl describe. It shows the object's details and, most importantly, the Events section — the chronicle of what Kubernetes did with the object (pulled the image, failed to start it, restarted it):
1kubectl describe pod <NAME> -n myapp
2kubectl describe svc myapp -n myapp # here we look at the Endpoints field (see above)And to see what the application itself prints, there's kubectl logs:
1kubectl logs <POD> -f # -f = follow the logs live
2kubectl logs <POD> -c myapp # -c = a specific container (if there are several)
3kubectl logs <POD> --previous # logs of the previous, crashed containerFor details on debugging, events, and logs, see the observability chapter.
Where to keep your manifests in the repo
A final but important question: where should these files live. The short answer is in Git, next to the service's code. Manifests that sit only on one developer's laptop and get applied "from the desktop" are a path to pain: no change history, no way to diff, no way to roll back, and everyone on the team running their own version of the cluster. By keeping manifests in the repository, you get versioning, review through pull requests, reproducibility, and environment parity — the very things we set up a local cluster for in the first place (see the production-like environments chapter).
The official configuration best practices boil down to a few simple rules:
- keep manifests under version control;
- group related objects of one application into a single file using
---; - apply the whole directory (
kubectl apply -f ./k8s/); - prefer YAML over JSON — it's more readable;
- don't repeat default values (less configuration means fewer errors);
- specify the latest stable
apiVersion.
How exactly to lay out files into folders is no longer part of the official docs but established practice. For a small service like myapp, a simple structure is enough: a k8s/ folder at the repository root, and inside it the manifests named by application and resource type:
1myapp/
2├── app/ # service code (FastAPI)
3├── Dockerfile
4├── k8s/
5│ ├── namespace.yaml
6│ ├── deployment.yaml
7│ └── service.yaml
8└── Tiltfile # appears in the Tilt chapterWhen the application grows and you have different environments (dev/prod with different settings), it's worth looking at Kustomize — a mechanism built into kubectl that lets you keep a common base (base/) and overlay environment-specific differences on top of it (overlays/dev, overlays/prod):
1kubectl apply -k overlays/devBut that's groundwork for the future — for local development of myapp, a flat k8s/ folder is more than enough. In the next chapter we'll teach Tilt to automatically apply these manifests and rebuild the image on every code change, so you don't have to run kubectl apply by hand.