Pods, Deployments & Services
Now the workhorses. This lesson is the core object model you'll use every day: Pods wrapped by ReplicaSets wrapped by Deployments; Services (ClusterIP / NodePort / LoadBalancer) that give a stable address to a moving set of Pods; ConfigMaps and Secrets for configuration; and labels & selectors — the glue that connects them all. You'll write your first real Deployment + Service and drive it with kubectl apply / get / describe / logs.
Learning objectives
- Explain the Pod → ReplicaSet → Deployment ownership chain and why you deploy via Deployments.
- Write a Deployment and expose it with a Service, connected by labels & selectors.
- Choose between ClusterIP, NodePort, and LoadBalancer Service types.
- Inject configuration with a ConfigMap and sensitive data with a Secret.
- Use namespaces to isolate environments, and drive it all with core
kubectlverbs.
1 · The ownership chain: Pod ← ReplicaSet ← Deployment
Picture the 3am page you don't want. A node dies, taking four of your app's instances with it. If you'd started those instances by hand — docker run on that box — they're simply gone, and nobody notices until users do. The whole reason Kubernetes exists is so that nobody has to notice: something is always watching the gap between "how many copies should be running" and "how many actually are," and closing it automatically. That "something" is the ownership chain you're about to meet, and it's the single most important structure in this entire track.
Here's the plain-language version before the vocabulary. A Pod is one running copy of your app — think of it as a single actor on stage. But you rarely want exactly one actor, and actors faint: Pods are deliberately disposable, killed and recreated all the time. So you don't manage actors, you hire a stage manager — a ReplicaSet — whose one obsessive job is "there must always be exactly N actors on stage; if one collapses, drag on a replacement." And because you also need to swap the whole cast for a new version of the play without going dark, you hire a director above the stage manager — the Deployment — who orchestrates the changeover gracefully. You write to the director; the director commands the stage manager; the stage manager keeps the actors alive.
The analogy that makes it click: this is a thermostat, not a light switch. A switch is imperative — you flip it, the light obeys once, done. A thermostat is declarative — you set "68°F" and it continuously compares actual to desired and acts to erase the difference, forever. You never tell Kubernetes "start a Pod"; you declare "I want 3 Pods of image X" and the chain reconciles reality toward that wish, over and over, whether the disturbance is a crashed process, a dead node, or you editing the number. That reconciliation loop is why a Kubernetes app self-heals and a hand-run container does not.
The classic beginner mistake is creating bare Pods (kubectl run a lone Pod) and being shocked when a deleted or crashed one stays dead. A naked Pod has no owner watching it, so nothing recreates it — you got an actor with no stage manager. The rule that saves you: in real systems you almost never create Pods or ReplicaSets directly; you create a Deployment and let it manufacture the layers beneath. Now the precise version.
You rarely touch the lower two directly. A Deployment is the object you write; it creates and manages a ReplicaSet, which in turn keeps the right number of Pods alive. The Deployment adds what a bare ReplicaSet lacks: rollouts and rollbacks. When you change the image, the Deployment spins up a new ReplicaSet and shifts Pods over gradually.
Read left-to-right, following the two arrows: the object you write is on the left, and each arrow points to the layer it creates and manages.
- The left blue box is the Deployment — the one object you actually author and apply.
- The first arrow points to the middle purple box, the ReplicaSet the Deployment creates; the second arrow points from it to the right green box, the Pods (×N) the ReplicaSet keeps alive.
- The rightmost group — the N identical Pods — is the payoff: the running copies of your app that the two layers above exist to keep at the desired count.
In short: A Deployment owns a ReplicaSet, which owns N identical Pods — you write the top box and the chain below it keeps the Pods alive.
2 · Your first Deployment
This is the moment Kubernetes stops being theory. Up to now the ownership chain was a story about directors and stage managers; the Deployment YAML below is where you actually declare your wish to the cluster in writing. Everything self-healing about Kubernetes flows from this one document — get comfortable reading it and you can read 80% of the manifests you'll ever meet.
The mental model to hold: a Deployment YAML is a recipe, not a plate of food. You are not describing three Pods that exist; you are handing the kitchen a recipe that says "keep three of these on the pass at all times, and here's exactly how to make one." The template: block is that recipe card — the cookie-cutter the ReplicaSet stamps out again and again. Change the recipe (a new image, say) and the Deployment doesn't patch the existing cookies; it bakes fresh ones and throws the stale ones away. That "replace, never edit in place" instinct is worth internalizing now, because it explains every rollout you'll ever watch.
The part that genuinely confuses everyone the first time is the two places labels appear, and why the same app: web is written twice. Read it as a promise and a proof. Under selector.matchLabels, the Deployment declares "the Pods I'm responsible for are the ones tagged app: web" — that's the promise. Under template.metadata.labels, it stamps that exact tag onto every Pod it creates — that's the proof it keeps its own promise. They must match because the Deployment identifies its children by label, not by a hidden ID: a Pod is "mine" purely because it wears the label my selector is looking for. Get these two out of sync and the Deployment is looking for Pods that don't exist while creating Pods it refuses to claim.
The single most common first-Deployment bug lives right here: you tweak the template's label but forget the selector (or vice versa), apply, and get either a validation error or a Deployment that reports zero Pods while Pods sit around unmanaged. Because the selector is immutable after creation, you can't just edit it back — you delete and recreate. So the habit to build from your very first Deployment: whenever you touch one label, scan for the other and make them identical. Here is a real Deployment: 3 replicas of a web image, with a selector that must match the Pod template's labels. That label match is not decoration — it's how the Deployment knows which Pods it owns.
selector.matchLabels is the Deployment's promise about which Pods it owns; template.metadata.labels is the proof it stamps on every Pod it makes. If the two drift apart, the Deployment hunts for Pods that don't exist while orphaning the ones it just created.
Read top-to-bottom: one Deployment splits into the two label declarations, and those declarations produce the Pods at the bottom.
- The top blue box is the single Deployment you write, asking for 3 replicas.
- The left purple box is
selector.matchLabels— the "promise" of which label a Pod must wear to count as owned. The right amber box istemplate.metadata.labels— the "proof" stamped onto each new Pod. The dashed must be equal link between them is the whole rule. - The green Pod row shows the result: three Pods all wearing
app: web, so the selector's query matches every one of them.
In short: The same app: web appears twice on purpose — selector promises, template proves; keep them identical or the Deployment manages zero Pods.
deployment.yamlapiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web # which Pods this Deployment manages
template: # the Pod template it stamps out
metadata:
labels:
app: web # MUST match the selector above
spec:
containers:
- name: app
image: ghcr.io/example/web:1.2.0 # pin a version
ports:
- containerPort: 8000
apply.shkubectl apply -f deployment.yaml
kubectl get deploy web # READY 3/3 when settled
kubectl get pods -l app=web # list the 3 Pods by label
kubectl rollout status deploy/web # block until the rollout is done
kubectl describe deploy web # events, replica counts, strategy
NAME READY UP-TO-DATE AVAILABLE AGE
web 3/3 3 3 40s
selector.matchLabels doesn't match the Pod template's labels, the Deployment either errors or manages zero Pods. This is the #1 first-Deployment bug. And the selector is immutable after creation — verify the current rules against the Kubernetes docs.3 · Services — a stable address for moving Pods
You built a self-healing Deployment. Congratulations — you also built a moving target. Every time a Pod dies and the ReplicaSet replaces it, the newcomer gets a brand-new IP address. The very feature that keeps your app alive means you can never hand a client a Pod's address and expect it to keep working: by tomorrow that Pod is gone and its IP belongs to something else. This is the problem a Service exists to solve, and until you feel the problem, the solution looks like pointless indirection.
The analogy that lands: a Service is a company phone number with a receptionist, and the Pods are employees who keep changing desks. You never memorize an employee's direct extension — you'd have to relearn it every week as people move. You dial the one published main number, and the receptionist routes you to whoever is currently doing that job. Employees come and go, desks get reassigned, but the number on the business card never changes. A Service is exactly that: one stable virtual IP and one stable DNS name (<name>.<namespace>.svc.cluster.local) that never moves, sitting in front of a churning set of Pods and forwarding each request to a healthy one.
Here's the mechanism that makes it more than a metaphor, and it's the reason this lesson keeps hammering labels: a Service does not hold a hard-coded list of Pod IPs. It holds a selector — "route to Pods labelled app: web" — and Kubernetes continuously watches which Pods currently satisfy it, keeping their live IPs in a set (EndpointSlices). Kill a Pod, the list shrinks itself; the ReplicaSet adds a fresh one, the list grows itself. The client dialing the stable number never knows any of this happened. So a Service is best understood as a saved label query that also load-balances, not as a proxy you configure with addresses.
The mistake this design punishes is the empty-endpoints Service, and it's so common it has its own knowledge-check below: you write a Service whose selector says app: web but your Pods are actually labelled app: webapp, apply both, and get a Service that resolves to an IP but connects to nothing — requests just hang. The Pods are perfectly healthy; the receptionist simply has no one on the routing list because nobody's badge matches the name she's calling. When a Service "does nothing," your first move is always kubectl get endpoints: empty means label mismatch, every time. Now, the three flavours of Service differ only in who is allowed to dial the number. Pods are cattle: they're created, killed, and rescheduled with new IPs constantly. Clients can't chase those IPs. A Service gives a single stable virtual IP and DNS name that load-balances across whichever Pods currently match its selector. Three types, from internal to external:
| Type | Reachable from | Typical use |
|---|---|---|
| ClusterIP (default) | Inside the cluster only | Service-to-service calls; the default |
| NodePort | Any node's IP on a high port | Dev/testing, or behind an external LB |
| LoadBalancer | The public internet (cloud LB) | Exposing a service externally on a cloud |
service.yamlapiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web # sends traffic to Pods with label app=web
ports:
- port: 80 # the Service's port
targetPort: 8000 # the container's port
# type: ClusterIP is the default (internal). Use LoadBalancer to expose externally.
svc.shkubectl apply -f service.yaml
kubectl get svc web # note the CLUSTER-IP
# Every Service gets in-cluster DNS: <name>.<namespace>.svc.cluster.local
kubectl run tmp --rm -it --image=curlimages/curl -- \
curl -s http://web.default.svc.cluster.local/healthz
NAME TYPE CLUSTER-IP PORT(S) AGE
web ClusterIP 10.96.140.21 80/TCP 8s
Read left-to-right, following the arrows: a request starts at the client and hops through the Service before reaching a Pod.
- The left blue box is the Client — it only ever talks to one address.
- The middle purple box is the Service, labelled "stable IP+DNS" — the one unchanging address the client dials.
- The two green Pod boxes on the right are the interchangeable backends: the arrows from the Service fan out to whichever Pods currently match its selector, so the client hits a fixed address while the Pods behind it churn.
In short: The client dials the Service's stable virtual IP and DNS name, and the Service load-balances the request across the matching Pods.
selector is matched continuously against Pod labels; the set of matching Pod IPs is tracked as EndpointSlices. Add or kill a Pod and the endpoints update automatically — that's why clients hit one address while Pods churn underneath.4 · ConfigMaps and Secrets
Imagine baking the staging database password into your container image, pushing it to a public registry, and only realizing weeks later. That's not a hypothetical — it's one of the most common ways credentials leak, and it happens because the "easy" thing is to hard-code config right into the app. Kubernetes gives you two objects precisely so you never have to: ConfigMaps and Secrets. Understanding them is what lets one single image travel unchanged from your laptop to staging to prod.
The principle underneath is worth stating plainly, because it's a career-long habit, not a Kubernetes quirk: an image is a sealed appliance; its settings are dialed in from outside at power-on. Think of a hotel TV. The manufacturer ships one identical model to thousands of rooms — they'd never burn "Room 412, guest = J. Smith" into the firmware. Instead the TV reads its room number and channel lineup from the hotel's system when it boots. Your container is that TV: build it once, then let the cluster inject LOG_LEVEL, feature flags, and API keys at startup so the same bytes behave differently in each environment. Bake config in and you've built a different TV for every room — a fresh image rebuild for every environment change, which is exactly the pain this avoids.
So why two objects instead of one? Because they answer different questions and deserve different handling. A ConfigMap is for the boring, shareable knobs — log level, feature flags, a service URL — things you'd happily print in a log or paste in a ticket. A Secret is for the things that would ruin your week if they leaked — API keys, passwords, tokens. Keeping them in separate object types means you can lock down Secrets with tighter access controls, keep them out of casual kubectl get -o yaml dumps, and reason about "where do our credentials live" as one clear category. Both are consumed the same two ways — as environment variables or as mounted files — so the split is about governance, not mechanics.
Now the trap that has burned more engineers than any other on this page: a Kubernetes Secret is not encrypted. The name oversells it. A Secret is merely base64-encoded, which is not a lock — it's an envelope anyone can open with one command (base64 -d). Treating "it's a Secret" as "it's protected" is the dangerous assumption; a Secret is base64, and base64 is reversible in your head. Real protection takes deliberate extra steps — encryption at rest for etcd, or pulling values from an external store like Vault or AWS Secrets Manager (K5) — and above all, never commit a real Secret value to git, because base64 in your commit history is plaintext to anyone who clones the repo. Never bake config or credentials into the image. A ConfigMap holds non-sensitive settings; a Secret holds sensitive ones. Both are injected at runtime as environment variables or mounted files, so the same image runs in dev and prod with different config.
Read left-to-right: the built image on the left is combined with injected config in the middle to produce a configured Pod on the right.
- The left blue box is your image, built once and never modified — the "sealed appliance."
- The middle boxes are the two config objects injected at power-on: the green ConfigMap for boring, shareable knobs, and the red Secret for sensitive values — red because it is only base64-encoded, not encrypted.
- The right purple box is the running Pod: the same image plus whichever ConfigMap/Secret you fed it, which is how one build behaves differently in dev versus prod.
In short: Build the image once, inject config at startup — the ConfigMap holds safe settings, the Secret holds sensitive ones (base64, so never commit it), and the same bytes run everywhere.
config.yamlapiVersion: v1
kind: ConfigMap
metadata:
name: web-config
data:
LOG_LEVEL: "info"
FEATURE_FLAGS: "search,export"
---
apiVersion: v1
kind: Secret
metadata:
name: web-secret
type: Opaque
stringData: # stringData: plain text, encoded for you on apply
API_KEY: "replace-me-in-a-real-secret-store"
---
# ...in the Deployment's container spec:
# envFrom:
# - configMapRef: { name: web-config }
# - secretRef: { name: web-secret }
5 · Namespaces, labels & selectors
A cluster with a hundred Pods and no organizing scheme is a junk drawer you're afraid to open. You've now met the objects; this section is about the two ideas that keep a growing pile of them legible — so you can find, group, route to, bill, and clean up your workloads without hand-tracking any of it. Skip this and every cluster you build eventually becomes unmanageable; adopt it early and even a large cluster stays searchable.
The two ideas do genuinely different jobs, and beginners routinely conflate them. Namespaces are walls; labels are sticky notes. A namespace is a room you put objects in — an object lives in exactly one namespace, names only have to be unique within a room, and you can hang per-room rules (quotas, access control) on the door. A namespace answers "where does this thing live?" Labels are different: they're arbitrary key/value tags you stick on objects, and one object can wear many at once (app: web, tier: frontend, env: prod, version: 1.2.0). A label answers "what kind of thing is this?" You don't choose one over the other — you use walls to isolate and sticky-notes to describe, together.
Here's why labels earn their own section rather than being a footnote: labels are the universal join key of Kubernetes. Almost nothing in the system finds its counterpart by name or ID — it finds it by matching a label query (a selector). Your Service found its Pods by label. Your Deployment owns its Pods by label. And the same trick powers everything downstream: monitoring scrapes "all Pods with tier: frontend," cost dashboards sum "everything tagged owner: payments," a canary rollout targets "only Pods with track: canary." Because it's the join key everywhere, a good labeling convention isn't tidiness — it's the difference between one kubectl get -l and an afternoon of guessing.
The mistake that bites hardest here is trusting a namespace as a security boundary. It looks like one — separate rooms, isolated names — so people assume a Pod in dev can't reach a Service in prod. By default it absolutely can: namespaces isolate names and are a home for quotas and RBAC, but they do not firewall network traffic. Any Pod can dial any Service in any namespace over cluster DNS unless you add a NetworkPolicy (K4). Read a namespace as a filing cabinet, not a locked vault. Namespaces partition a cluster into virtual sub-clusters — a common pattern is one per environment (dev, staging, prod) or per team. Objects in different namespaces can share names and are isolated by default. Labels are arbitrary key/value tags on objects; selectors query them. Together they're how nearly everything in k8s finds everything else.
app=web is the join key every Service, monitor, and cost dashboard matches on. Note: namespaces isolate names, not network traffic.
Read the two boxed rooms across the top, then follow the selector at the bottom up to the Pods it matches.
- The two outer boxes are namespaces (
prod,dev) — the "walls." Each holds its own Pods, and the same names (app:web,app:api) can appear in both because names only need to be unique within a room. - The coloured Pod boxes carry labels (the "sticky notes"): green
app:web, amberapp:api. - The bottom purple box is a
selector: app=web. The arrows show it reaching theapp:webPods regardless of which namespace they sit in — that's why labels are the universal join key, and why a namespace wall is not a network firewall by default.
In short: Namespaces answer "where does it live" and labels answer "what is it"; selectors match labels across walls, so labels — not names — are how everything in k8s finds everything else.
ns.shkubectl create namespace staging
kubectl apply -f deployment.yaml -n staging # deploy into a namespace
kubectl get pods -A # -A = across all namespaces
# Labels drive selection everywhere:
kubectl get pods -l 'app=web,tier!=batch' # boolean label selectors
kubectl label pod web-abc123 canary=true # add a label on the fly
kubectl get pods -l canary=true # now query by it
app, tier, env, version) — it pays off across every tool.✓ Checkpoint — you can move on when you can…
- Draw the Deployment → ReplicaSet → Pod chain and say what each layer adds.
- Write a Service whose selector matches a Deployment's Pod labels, mapping port 80 → 8000.
- State when you'd use ClusterIP vs NodePort vs LoadBalancer.
- Explain why a Secret is not encrypted by default and what to do about it.
- Use
kubectl get pods -lwith a label selector to filter Pods.
You applied a Deployment and a Service, but curl to the Service returns nothing and kubectl get endpoints web shows no addresses. The Pods are Running. What is the most likely cause?
Show answer
selector does not match the Pods' labels. A Service populates its endpoints from Pods whose labels satisfy its selector; if they don't match, the endpoint list is empty and there's nowhere to route, so requests hang or fail even though the Pods are perfectly healthy. Fix it by making the Service selector (e.g. app: web) exactly match the labels on the Deployment's Pod template. Confirm with kubectl describe svc web and kubectl get pods --show-labels.A teammate wants to expose an internal-only backend API to other services in the cluster and reaches for a LoadBalancer Service. Why is that wrong, and what should they use?
Show answer
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: The Deployment+Service pair is the atom of almost every k8s app; writing it from memory is the first real milestone.
Your task: Write a Deployment of 2 replicas of a web image on container port 8080, and a ClusterIP Service that maps port 80 to it.
Requirements:
- The Deployment's selector must match its Pod template labels
- The Service's selector must match the same labels
- Map Service port 80 → targetPort 8080
- Pin the image to a version, never
:latest
💡 Hint: One label (e.g. app: api) is the join key across all three objects.
Show solution
The label app: api ties the Deployment, its Pods, and the Service together:
apiVersion: apps/v1
kind: Deployment
metadata: { name: api, labels: { app: api } }
spec:
replicas: 2
selector: { matchLabels: { app: api } }
template:
metadata: { labels: { app: api } }
spec:
containers:
- name: app
image: ghcr.io/example/api:1.0.0
ports: [ { containerPort: 8080 } ]
---
apiVersion: v1
kind: Service
metadata: { name: api }
spec:
selector: { app: api }
ports: [ { port: 80, targetPort: 8080 } ]Apply with kubectl apply -f, then kubectl get deploy,svc,pods -l app=api. The Service will show endpoints once the 2 Pods are Ready.
Context: Externalizing config is what lets one image run in every environment.
Your task: Add a ConfigMap and a Secret to the app and consume both as environment variables via envFrom.
Requirements:
- ConfigMap holds LOG_LEVEL and a feature flag
- Secret holds an API_KEY using
stringData - The container consumes both with
envFrom - Note why the Secret value must not be committed to git
💡 Hint: envFrom pulls every key in a ConfigMap/Secret in as an env var at once.
Show solution
apiVersion: v1
kind: ConfigMap
metadata: { name: api-config }
data: { LOG_LEVEL: "info", FEATURE_SEARCH: "true" }
---
apiVersion: v1
kind: Secret
metadata: { name: api-secret }
type: Opaque
stringData: { API_KEY: "set-via-CI-or-a-secret-store" }Consume both in the container spec:
envFrom:
- configMapRef: { name: api-config }
- secretRef: { name: api-secret }Why not commit the value: a Secret is only base64-encoded in etcd, not encrypted, so committing the manifest with a real API_KEY leaks it in git history in effectively plaintext. Inject the real value from CI or an external secret store, and keep only a placeholder in the repo.
Context: The most common broken-Service symptom is empty endpoints; diagnosing it fast is a core operating skill.
Your task: A Service returns nothing though its Pods are Running. Give the exact kubectl sequence to diagnose it and the two most likely root causes.
Requirements:
- Check the Service's endpoints
- Compare the Service selector to the Pod labels
- Also consider a port mismatch (targetPort vs containerPort)
- State the fix for each cause
💡 Hint: No endpoints = no Pods matched the selector; wrong port = matched but nothing listening.
Show solution
Diagnose:
kubectl get endpoints web # empty? no Pods matched the selector
kubectl get pods --show-labels # what labels do the Pods actually have?
kubectl describe svc web # selector + target port
kubectl get svc web -o yaml # confirm targetPort matches containerPortCause 1 — selector/label mismatch (empty endpoints). The Service selector doesn't match the Pod labels, so no endpoints are populated. Fix: align the selector with the Pod template labels.
Cause 2 — port mismatch (endpoints present, still fails). Endpoints exist but targetPort points at a port the container isn't listening on. Fix: set targetPort to the real containerPort.
Rule of thumb: no endpoints → label problem; endpoints but connection refused → port problem.
Context: Understanding a rollout as two ReplicaSets scaling in opposite directions demystifies both deploys and rollbacks.
Your task: Explain what objects exist and how their replica counts move when you change a Deployment's image from v1 to v2 with the default rolling strategy, and how rollback works.
Requirements:
- Describe the old and new ReplicaSets during the rollout
- Explain what
maxSurgeandmaxUnavailablecontrol - Explain what
kubectl rollout undoactually does
💡 Hint: A Deployment never edits Pods in place — it shifts them between ReplicaSets.
Show solution
During the rollout, the Deployment holds two ReplicaSets: the old (v1) and a new (v2). It scales the new RS up and the old RS down in steps until v2 = desired and v1 = 0. Pods are never mutated in place — old Pods are deleted, new ones created.
The two knobs bound the disruption: maxSurge = how many extra Pods above desired may exist mid-rollout (speed), maxUnavailable = how many below desired may be missing (safety). With maxUnavailable: 0 you never drop below full capacity — at the cost of needing surge headroom.
Rollback is cheap because the old ReplicaSet still exists (scaled to 0). kubectl rollout undo deploy/web just scales the previous RS back up and the current one down — reversing the arithmetic. That's why rollback is seconds, not a rebuild.
Context: A labeling and namespace convention adopted early is what keeps a growing cluster legible.
Your task: Design a namespace layout and a label schema for a company running several apps across dev/staging/prod, so Services, monitoring, and cost allocation all work off it.
Requirements:
- Decide the namespace boundary (by env, by team, or both)
- Define a minimal required label set every workload must carry
- Show one query each label enables (ops, cost, canary)
- Note one thing namespaces do NOT isolate by default
💡 Hint: Recommended labels cluster around app, component, version, part-of, managed-by.
Show solution
Namespaces: partition by environment × team — e.g. payments-prod, payments-staging, search-prod. This keeps prod blast radius contained and lets you apply per-namespace quotas and RBAC.
Required labels (aligned to the common recommended set): app.kubernetes.io/name, app.kubernetes.io/component, app.kubernetes.io/version, app.kubernetes.io/part-of, app.kubernetes.io/managed-by, plus a team label like owner: payments.
What each unlocks: -l app.kubernetes.io/name=web selects a service for ops; owner=payments drives cost allocation and dashboards; a track=canary label lets a Service or rollout target only canary Pods.
The gotcha: namespaces isolate names and are an RBAC/quota boundary, but they do not isolate network traffic by default — any Pod can reach a Service in another namespace unless you add NetworkPolicies (K4). Don't mistake a namespace for a security wall.
Context: Representative scenario: you're handed a frontend and a backend container and asked to run them together in a staging namespace, with the frontend reachable and the backend internal-only.
Your task: Produce the full manifest set: two Deployments, two Services (one internal, one external-ish), config, and the commands to deploy and verify in a namespace.
Requirements:
- Backend: ClusterIP Service, consumed by the frontend via in-cluster DNS
- Frontend: a Service the outside can reach (LoadBalancer or NodePort for now)
- Config/secret injected, image versions pinned
- Give the deploy + verify commands scoped to the namespace
💡 Hint: The frontend calls the backend at backend.staging.svc.cluster.local.
Show solution
Backend (internal ClusterIP):
apiVersion: apps/v1
kind: Deployment
metadata: { name: backend, labels: { app: backend } }
spec:
replicas: 2
selector: { matchLabels: { app: backend } }
template:
metadata: { labels: { app: backend } }
spec:
containers:
- name: app
image: ghcr.io/example/backend:2.1.0
ports: [ { containerPort: 8000 } ]
envFrom: [ { configMapRef: { name: backend-config } } ]
---
apiVersion: v1
kind: Service
metadata: { name: backend }
spec:
selector: { app: backend }
ports: [ { port: 80, targetPort: 8000 } ] # ClusterIP (default)Frontend reaches the backend by DNS at http://backend.staging.svc.cluster.local and is itself exposed:
apiVersion: apps/v1
kind: Deployment
metadata: { name: frontend, labels: { app: frontend } }
spec:
replicas: 2
selector: { matchLabels: { app: frontend } }
template:
metadata: { labels: { app: frontend } }
spec:
containers:
- name: app
image: ghcr.io/example/frontend:2.1.0
ports: [ { containerPort: 3000 } ]
env:
- name: BACKEND_URL
value: "http://backend.staging.svc.cluster.local"
---
apiVersion: v1
kind: Service
metadata: { name: frontend }
spec:
type: LoadBalancer # externally reachable (or NodePort locally)
selector: { app: frontend }
ports: [ { port: 80, targetPort: 3000 } ]Deploy & verify in the namespace:
kubectl create namespace staging
kubectl apply -n staging -f backend.yaml -f frontend.yaml
kubectl get all -n staging
kubectl get svc frontend -n staging # note EXTERNAL-IP once assignedThe backend stays internal (ClusterIP, no external IP); the frontend gets an external address and reaches the backend over cluster DNS. In K4 you'd replace the frontend LoadBalancer with an Ingress for TLS and host-based routing.