The Problem
Everyone copies a Pod YAML off the internet, tweaks the image, and ships it — without knowing what half the fields do. Then a Pod gets OOMKilled, or lands on the wrong node, or restarts forever, and there's no way to reason about it because the manifest is a black box. A Pod spec is small and every field earns its place. Read one carefully, once, and you can read every manifest after it.
Every Kubernetes object follows the same four-part shape: apiVersion, kind, metadata, and spec. A Pod is the simplest workload object, so it's the perfect place to learn that shape.
The Complete Manifest
Here is a fully annotated Pod. We'll walk through it top to bottom.
apiVersion: v1 # core API group, version 1
kind: Pod # the object type
metadata:
name: web # unique within the namespace
labels: # key/value tags used by selectors
app: web
tier: frontend
annotations: # non-identifying metadata (tools, notes)
team: platform
spec:
containers:
- name: web # container name, unique within the pod
image: nginx:1.27 # pin a version, never use :latest in prod
ports:
- containerPort: 80 # documents the port the app listens on
env:
- name: LOG_LEVEL
value: "info"
- name: DB_PASSWORD # pull secrets from a Secret, not plain text
valueFrom:
secretKeyRef:
name: db-creds
key: password
resources:
requests: # guaranteed minimum (used for scheduling)
cpu: "100m"
memory: "128Mi"
limits: # hard ceiling (exceed memory = OOMKilled)
cpu: "500m"
memory: "256Mi"
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
livenessProbe:
httpGet: { path: /, port: 80 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /, port: 80 }
periodSeconds: 5
volumes:
- name: html
configMap:
name: web-content
restartPolicy: Always # Always | OnFailure | Never
nodeSelector:
disktype: ssd # only schedule onto nodes with this label
apiVersion and kind
apiVersion tells the API server which API group and version to validate against — Pods live in the core group, so it's just v1. kind names the object type. Together they decide the schema. Get these wrong and the server rejects the manifest before anything else is read.
metadata — The Object's Identity
- name — must be unique within the namespace. This is how you reference the Pod.
- labels — arbitrary key/value pairs that other objects select on. Services, Deployments, and
kubectl get -l app=weball rely on labels. This is the most important metadata field. - annotations — also key/value, but not used for selection. They hold free-form metadata: ownership, tooling config, change-cause notes.
The practical difference: you filter and target workloads by labels; you attach descriptive data with annotations.
spec.containers — What Actually Runs
image
The container image, ideally pinned to a specific tag or digest. Using :latest in production is a reliability trap — you can't tell what's running and rollbacks become guesswork.
ports
containerPort documents the port the process listens on. It's informational — it doesn't publish anything by itself — but it's good practice and some tooling reads it.
env
Environment variables. Inline literals for non-sensitive config, valueFrom.secretKeyRef for secrets, and valueFrom.configMapKeyRef for shared config. Never hard-code passwords as plain value entries.
resources — requests vs limits
This is the field people skip and regret:
| Field | Meaning |
|---|---|
| <code class="inline-code">requests</code> | The amount reserved for the container. The <strong>scheduler</strong> uses this to decide which node fits. |
| <code class="inline-code">limits</code> | The hard ceiling. Exceed the <strong>memory</strong> limit and the container is <code class="inline-code">OOMKilled</code>; exceed the <strong>CPU</strong> limit and it's throttled, not killed. |
Set requests so the scheduler places Pods sensibly, and set limits to protect the node from a runaway container.
volumeMounts
Mounts a volume (declared below in spec.volumes) into the container's filesystem at mountPath. The name must match a volume name.
probes
livenessProbe, readinessProbe, and startupProbe tell the kubelet whether the app is alive, ready for traffic, and finished starting. See the Pod lifecycle guide for how each one changes behavior.
spec.volumes
Volumes are declared at the Pod level and mounted into one or more containers. Common types:
configMap/secret— surface config or secrets as files.emptyDir— scratch space that lives as long as the Pod.persistentVolumeClaim— durable storage that survives Pod restarts.
restartPolicy and nodeSelector
- restartPolicy —
Always(services),OnFailure(jobs), orNever. Applies to all containers in the Pod. - nodeSelector — the simplest node-targeting mechanism: schedule only onto nodes carrying the given label. For richer rules use affinity, taints, and tolerations.
In Production, Prefer a Deployment
A bare Pod has no self-healing. If its node dies, the Pod is gone — nothing recreates it. In real systems you almost never apply a Pod directly. Instead you create a Deployment, which manages a ReplicaSet that keeps the desired number of Pods running, handles rolling updates, and recreates Pods after node failures.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template: # this is a Pod spec — the same fields you just learned
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:1.27
Notice the template is just the Pod spec. Everything you learned above carries straight over — a Deployment is a manager wrapped around the same Pod definition.
# Validate a manifest without applying it
kubectl apply -f pod.yaml --dry-run=server
# See the fully-populated object (with defaults filled in)
kubectl get pod web -o yaml
Common Mistakes to Avoid
- Using
image: latest. You lose track of what's deployed and can't roll back reliably. Pin versions. - Omitting resource requests and limits. Without requests the scheduler can overpack a node; without a memory limit one Pod can starve the whole node.
- Confusing labels and annotations. Selectors only match labels. Put anything you need to query into labels, not annotations.
- Hard-coding secrets in
env.value. UsesecretKeyRef. Plain-text secrets end up in Git and inkubectl get -o yaml. - Running bare Pods in production. No controller means no self-healing. Use a Deployment (or StatefulSet/DaemonSet/Job as appropriate).
FAQ
What is the difference between resource requests and limits?
A request is the amount of CPU or memory reserved for the container and is what the scheduler uses to place the Pod on a node. A limit is the maximum the container may use — exceeding the memory limit gets the container OOMKilled, while exceeding the CPU limit only throttles it. Set requests for scheduling accuracy and limits to protect the node.
Why should I use a Deployment instead of a Pod?
A bare Pod has no controller watching it, so if its node fails the Pod simply disappears and nothing recreates it. A Deployment manages a ReplicaSet that maintains the desired replica count, performs rolling updates, and reschedules Pods after failures. The Deployment's template is just a Pod spec, so everything you know about Pods still applies.
What is the difference between labels and annotations?
Labels are key/value pairs that selectors use to find and group objects — Services and Deployments match Pods by their labels. Annotations are also key/value pairs but are ignored by selectors; they hold descriptive or tooling metadata. If you need to query or target an object by a value, it belongs in labels.
Do I need to declare containerPort for my app to receive traffic?
No. containerPort is informational and does not open or publish a port by itself — the container listens regardless. Traffic routing is handled by Services and their target ports. Declaring containerPort is still good practice because it documents intent and some tooling reads it.