Deploy a Photography Gallery API on K8s
Containerize a REST API and deploy it to a local Kubernetes cluster.
Introduction
30 Second Summary
Every time you scroll through a photography app or browse an online gallery, there's an API behind the scenes serving up those images and metadata to your screen. If that API goes down, the entire experience disappears.
In this project, you will build a photography gallery API, package it into a container, and deploy it to a local Kubernetes cluster that keeps your API running even when things break.
What You'll Build
You'll have a photography platform API running on your own machine that heals itself when pods crash, balances traffic across replicas, and ships new features without a single second of downtime.
By the end of this project, you'll have:
- A live REST API serving photographer profiles and photo collections. Hit /api/photos and get back a gallery of photography metadata with titles, image URLs, camera info, and tags.
- Load balancing you can see with your own eyes. Refresh your API repeatedly and watch the X-Served-By header rotate across different pods, proving Kubernetes is spreading traffic for you.
- Zero-downtime deployments in action. Ship a new API version with a search feature and watch the old version get seamlessly replaced without dropping a single request.
- Secret Mission: add liveness and readiness probes so Kubernetes can detect when your API is truly broken and stop routing photographers' traffic to unhealthy pods.
Are there any prerequisites?
You'll need a Mac with Node.js already installed and Homebrew available for installing new tools. No prior Docker or Kubernetes experience is needed.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Install Your Tools
Running a Kubernetes cluster locally requires three tools working together.
Docker builds and runs container images. Minikube creates a single-node Kubernetes cluster inside Docker on your Mac. And kubectl is the CLI that lets you talk to that cluster.
In this step, get ready to:
- Install Docker Desktop.
- Install Minikube and kubectl via Homebrew.
- Verify the full toolchain is working.
Install Docker Desktop
Docker Desktop provides the container engine that both you and Minikube need. It runs in the background and powers every container on your machine.
- Check if Docker is already installed by running this command:
docker --version
✔️ I see a version number
Docker Desktop is already installed. You can move on to the next substep.
- Confirm Docker Desktop is running by looking for the whale icon in your menu bar (top right of your screen).
ⓧ Command not found
Docker is not installed yet. Follow these steps to install it.
- Open the Docker Desktop for Mac download page: docs.docker.com/desktop/setup/install/mac-install
- Click the download button for your Mac (Apple Silicon or Intel).
- Double-click the downloaded Docker.dmg file to open the installer.
- Drag the Docker icon into the Applications folder.
- Press Cmd+Space to open Spotlight, type Docker, and press Enter to launch it.
- Docker will display the Docker Subscription Service Agreement. Select Accept to continue.
- On the configuration screen, keep the default settings and select Finish.
- Wait for the whale icon to appear in your menu bar. This means Docker is running.
Is Docker Desktop free?
Docker Desktop is free for personal use, education, and small businesses (fewer than 250 employees and less than $10 million in annual revenue). This learning project is fully covered.
- Verify Docker is working by running:
docker --version
You should see a version number printed in your terminal.
Still seeing command not found?
Make sure you waited for the whale icon to appear in the menu bar before running the command. Docker takes a moment to finish starting up.
If the icon is there but the command still fails, close your terminal and open a new one. Docker registers its CLI tools on launch, so an older terminal session may not see them yet.
Why isn't docker --version working after I installed Docker Desktop?
Install Minikube and kubectl
Minikube creates a local Kubernetes cluster that runs inside Docker. The kubectl CLI sends commands to that cluster. You will install both with a single Homebrew command.
- Check if Minikube is already installed by running:
minikube version
✔️ I see a version number
Minikube is already installed.
- Also confirm kubectl is installed by running:
kubectl version --client
If you see a version number, you are ready to start your cluster. Skip to the next action below (starting Minikube).
ⓧ Command not found
Neither Minikube nor kubectl is installed. Install both with Homebrew.
- Install Minikube and kubectl by running:
brew install minikube kubectl
Homebrew will download and link both tools. This may take a minute or two.
- Verify the installs succeeded by running:
minikube version
You should see a version number. Then confirm kubectl:
kubectl version --client
You should see client version information printed.
Homebrew install failed?
If Homebrew cannot find the packages, run brew update first to refresh the package list, then retry the install command.
If which minikube returns nothing after install, you may need to unlink and relink. Run brew unlink minikube && brew link minikube.
Why can't my terminal find minikube after brew install?
- Start your local Kubernetes cluster by running:
minikube start
What does minikube start do?
This command creates a single-node Kubernetes cluster inside a Docker container on your Mac. It downloads the necessary Kubernetes components and configures kubectl to point at this new cluster automatically.
The first run takes 1-2 minutes as it pulls container images. You will see progress output in your terminal.
Minikube failed to start?
Make sure Docker Desktop is running. Look for the whale icon in your menu bar. Minikube uses Docker as its driver and cannot start without it.
If you see an error about insufficient resources, check that you have at least 2 CPUs and 2GB of free memory available.
Minikube start is failing on my Mac. How do I fix it?
Verify the full toolchain
All three tools are installed. Now confirm they are working together as a live cluster.
Before you run the next command, what do you think kubectl get nodes will show you?
- Confirm your cluster is live by running:
kubectl get nodes
You should see one node named minikube with STATUS Ready. This means your cluster is up and accepting workloads.
- See your cluster's control plane URL by running:
kubectl cluster-info
You should see the Kubernetes control plane address printed. This is the internal endpoint that kubectl uses to communicate with your cluster.
Node shows NotReady?
The cluster may still be initialising. Wait 30 seconds and run kubectl get nodes again. The status should switch to Ready once all components finish starting.
If it stays NotReady for more than a minute, run minikube status to check which component is stuck, then restart with minikube delete followed by minikube start.
My minikube node is stuck in NotReady status. How do I fix it?
Your local Kubernetes cluster is live and ready. Next up, you will build a photography API and package it into a Docker container.
Build and Containerize the Photography API
Your Kubernetes cluster is ready and waiting. In this step, you'll build the thing it will run: a photography gallery REST API that serves photographer profiles and curated photo collections.
Before Kubernetes enters the picture, you need a working containerized app. You'll write an Express.js API, package it into a Docker image, and prove it responds to requests from inside a container.
In this step, get ready to:
- Create a REST API with Express.js that serves photography data.
- Write a Dockerfile that packages the API into a portable container image.
- Build and run the container locally to prove it works before Kubernetes takes over.
Create the Express API
You need a project folder to hold your API code. This folder will eventually become the build context for your Docker image too.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir photo-api
cd photo-api
- Confirm the folder exists by running:
ls ~/Desktop | grep photo-api
You should see photo-api in the output.
- Initialize a Node.js project by running this command:
npm init -y
This creates a package.json file that tracks your project's dependencies and metadata.
- Install Express as your API framework by running:
npm install express@4.21.2
Install failed?
Make sure you're inside the photo-api folder. Run pwd to check your current directory.
If you see a permissions error, try prefixing the command with sudo. help me fix my npm install error
Now you'll write the API itself. The photography data (photographer profiles and photo metadata) lives in memory as arrays. Later, Kubernetes will treat each copy of this server as an identical, replaceable replica.
- Open Cursor with your project folder by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Cursor, and pressing Enter.
- In Cursor, click File in the top menu bar.
- Click Open Folder.
- Select the photo-api folder on your Desktop, then click Open.
- Create a new file by clicking File then New File.
- Name the file server.js and press Enter.
- Add the imports, configuration, and photography data to server.js by pasting this code:
const express = require("express");
const os = require("os");
const app = express();
const PORT = 3000;
const VERSION = "1.0";
const photographers = [
{ id: 1, name: "Ava Chen", bio: "Landscape and nature photographer based in Oregon", style: "Nature" },
{ id: 2, name: "Marcus Rivera", bio: "Street photography capturing urban stories", style: "Street" },
{ id: 3, name: "Lina Johansson", bio: "Minimalist architecture and design", style: "Architecture" }
];
const photos = [
{ id: 1, title: "Golden Hour Summit", photographerId: 1, url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4", camera: "Sony A7IV", iso: 200, aperture: "f/8", tags: ["landscape", "mountains", "golden-hour"] },
{ id: 2, title: "Rainy Crossing", photographerId: 2, url: "https://images.unsplash.com/photo-1428592953211-077101b2021b", camera: "Fuji X-T5", iso: 800, aperture: "f/2.8", tags: ["street", "rain", "urban"] },
{ id: 3, title: "Concrete Curves", photographerId: 3, url: "https://images.unsplash.com/photo-1511818966892-d7d671e672a2", camera: "Canon R5", iso: 100, aperture: "f/11", tags: ["architecture", "minimalist", "concrete"] },
{ id: 4, title: "Forest Mist", photographerId: 1, url: "https://images.unsplash.com/photo-1440342359743-84fcb8c21c7c", camera: "Sony A7IV", iso: 400, aperture: "f/5.6", tags: ["landscape", "forest", "mist"] },
{ id: 5, title: "Neon Nights", photographerId: 2, url: "https://images.unsplash.com/photo-1514565131-fce0801e5785", camera: "Fuji X-T5", iso: 1600, aperture: "f/1.4", tags: ["street", "neon", "night"] },
{ id: 6, title: "Glass Tower", photographerId: 3, url: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab", camera: "Canon R5", iso: 100, aperture: "f/8", tags: ["architecture", "glass", "skyscraper"] }
];
What does this code do?
- express is your web framework for handling HTTP requests. os gives you the machine's hostname, which Kubernetes later sets to the pod name.
- VERSION tracks which release of the API is running. You'll bump this to 2.0 in a later step to trigger a rolling update.
- The photographers and photos arrays are your in-memory database. Each photo has real Unsplash URLs, camera settings, and tags you'll query later.
- Below the photos array, add the middleware and the root info route by pasting this code:
app.use((req, res, next) => {
// Attach pod identity and version to every response
res.set("X-Served-By", os.hostname());
res.set("X-API-Version", VERSION);
next();
});
app.get("/", (req, res) => {
res.json({
name: "Photography Gallery API",
version: VERSION,
pod: os.hostname(),
endpoints: ["/api/photos", "/api/photographers", "/api/photographers/:id"]
});
});
app.listen(PORT, () => {
console.log(`Photo API running on port ${PORT} - Version ${VERSION} - Pod: ${os.hostname()}`);
});
What does this code do?
- The app.use middleware adds X-Served-By and X-API-Version headers to every response. When you have multiple pods, the X-Served-By header reveals which pod handled each request.
- The root / route returns API info including the hostname. This becomes a quick health check once running on Kubernetes.
- app.listen starts the server on port 3000.
- Save server.js with Cmd+S (macOS) or Ctrl+S (Windows).
- Return to your terminal and start the server by running:
node server.js
You should see: Photo API running on port 3000 - Version 1.0 - Pod: followed by your machine's hostname.
- Open a second terminal tab (Cmd+T in most terminals) and test the root route by running:
curl http://localhost:3000
You should see a JSON response with the API name, version, and list of endpoints.
Seeing 'command not found' or 'connection refused'?
- If node server.js gave an error about express not found, make sure you ran npm install inside the photo-api folder.
- If curl shows connection refused, check that your first terminal still shows the server running. It takes over that terminal session.
- help me get my Node.js Express server running
The root route works. Now you'll add the photography-specific API routes that serve real data.
- Stop the running server by pressing Ctrl+C in the terminal where it's running.
- Switch back to server.js in Cursor. Find the app.listen line and add this code directly above it:
app.get("/api/photos", (req, res) => {
res.json(photos);
});
app.get("/api/photos/:id", (req, res) => {
// Look up a single photo and include its photographer
const photo = photos.find(p => p.id === parseInt(req.params.id));
if (!photo) return res.status(404).json({ error: "Photo not found" });
const photographer = photographers.find(p => p.id === photo.photographerId);
res.json({ ...photo, photographer });
});
app.get("/api/photographers", (req, res) => {
res.json(photographers);
});
app.get("/api/photographers/:id", (req, res) => {
// Look up a photographer and attach their full portfolio
const photographer = photographers.find(p => p.id === parseInt(req.params.id));
if (!photographer) return res.status(404).json({ error: "Photographer not found" });
const portfolio = photos.filter(p => p.photographerId === photographer.id);
res.json({ ...photographer, portfolio });
});
app.get("/healthz", (req, res) => {
res.status(200).json({ status: "ok" });
});
What do these routes do?
- /api/photos returns the full gallery. /api/photos/:id returns a single photo with its photographer attached.
- /api/photographers lists all photographers. /api/photographers/:id returns one photographer with their full portfolio of photos.
- /healthz is a simple health check endpoint that always returns 200. Kubernetes will use this later to know if your pod is alive.
- Save server.js with Cmd+S.
- Restart the server by running:
node server.js
- In your second terminal tab, test the photos endpoint by running:
curl http://localhost:3000/api/photos
You should see a JSON array of 6 photos, each with a title, Unsplash URL, camera info, and tags.
- Test the photographer profile endpoint by running:
curl http://localhost:3000/api/photographers/1
You should see Ava Chen's profile with her 2 landscape photos attached as a portfolio.
Getting an empty response or error?
- Make sure you added the new routes ABOVE app.listen, not below it. Routes registered after listen won't work.
- Check for missing commas or brackets in your code. A syntax error will crash the server on startup.
- help me debug my Express API routes
- Stop the server with Ctrl+C. You're done testing it directly. Next you'll package it into a Docker container.
✔️ Awesome, I've got everything!
Great. Make sure you've saved server.js and that it runs without errors.
ⓧ I'd like to double check the full code
Your server.js combines the three code blocks above. Scroll up and confirm you have all three sections in this order:
- Imports, constants, photographers array, and photos array (the first code block).
- Middleware (app.use) and root / route (from the second code block).
- All API routes (/api/photos, /api/photographers, /healthz) from the third code block, placed directly above app.listen.
- app.listen at the very bottom.
Write the Dockerfile
A Dockerfile is a recipe that tells Docker how to package your application into an image. The image contains everything your API needs to run: Node.js, your code, and your dependencies.
- In Cursor, create a new file called Dockerfile (no extension) in your photo-api folder.
- Add this content to Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]
What does this Dockerfile do?
- FROM node:22-alpine starts from a lightweight Node.js 22 image built on Alpine Linux (about 50MB instead of 900MB).
- Copying package.json first and running npm ci before copying source code means Docker caches your dependencies. If you only change server.js, Docker skips the install step on rebuild.
- npm ci --omit=dev installs exact versions from package-lock.json without dev dependencies. This keeps your container lean.
You also need a .dockerignore file to prevent your local node_modules from being copied into the image (Docker installs fresh dependencies itself).
- Create a new file called .dockerignore in the same folder and add this content:
node_modules
npm-debug.log
- Save both Dockerfile and .dockerignore.
File not showing in the sidebar?
- Files starting with a dot (like .dockerignore) are hidden by default in some views. In Cursor's Explorer sidebar, they should still appear. If not, make sure you created the file in the photo-api folder root, not inside a subfolder.
Build and run the container
With your Dockerfile ready, you can now build a Docker image and run it as a container. This is the exact image Kubernetes will deploy in the next step.
- Build the Docker image by running this command in your terminal (make sure you're still in the photo-api folder):
docker build -t photo-api:v1 .
What does this command do?
- docker build reads your Dockerfile and executes each instruction to produce an image.
- -t photo-api:v1 tags the image with the name photo-api and version v1. You'll later build a v2 for the rolling update.
- The . at the end tells Docker to use the current folder as the build context (where it looks for your files).
You should see Docker step through each layer (FROM, WORKDIR, COPY, RUN, etc.) and end with a success message.
- Run your containerized API by running:
docker run -p 3000:3000 photo-api:v1
You should see the same startup log: Photo API running on port 3000. But now it's running inside a container, not directly on your machine.
What does -p 3000:3000 mean?
The -p flag maps port 3000 on your machine to port 3000 inside the container. Without it, the container's port would be isolated and unreachable from your browser or curl.
Before you test it, what do you think you'll see when you curl the photos endpoint? Will it look different from when you ran the API directly with Node.js?
- In your second terminal tab, test the containerized API by running:
curl http://localhost:3000/api/photos
You should see the same JSON array of 6 photos with titles, Unsplash image URLs, and camera settings. The data is identical, but now it's served from inside a Docker container.
Port already in use?
- If you see an error about port 3000 being taken, make sure you stopped the earlier node server.js process with Ctrl+C. Only one process can use a port at a time.
- If Docker itself isn't running, open Docker Desktop and wait for the whale icon to show a steady green status.
- help me fix my Docker container port issue
- Stop the container by pressing Ctrl+C in the terminal where it's running.
Your photography API is containerized and proven to work. Next up, you'll hand this container to Kubernetes and let the cluster manage it for you.
Deploy to Kubernetes
Your photography API is containerized and responding to requests from inside Docker. That is a great start, but right now you are managing it manually.
If the container crashes, nobody restarts it. Kubernetes takes over this management so your photography platform stays online without you babysitting processes. In this step, you will hand your container to Kubernetes and access the API through the cluster.
In this step, get ready to:
- Load your Docker image into Minikube's internal Docker daemon.
- Write a deployment YAML that tells Kubernetes what to run.
- Expose your API as a Kubernetes Service and access it through the cluster.
Load your image into Minikube
Minikube runs its own Docker daemon inside the cluster, completely separate from the Docker on your Mac. Your locally-built photo-api:v1 image exists in your local Docker, but Minikube cannot see it.
The fix is to point your terminal's Docker CLI at Minikube's Docker daemon, then rebuild the image there.
- Switch your terminal's Docker context to Minikube's daemon by running this command:
eval $(minikube docker-env)
What does this command do?
This command sets environment variables in your current terminal session so that any docker command you run talks to Minikube's internal Docker daemon instead of your local one.
It only affects the current terminal window. If you open a new terminal, you would need to run it again.
- Rebuild the image inside Minikube's Docker daemon by running:
docker build -t photo-api:v1 .
You should see the familiar build output, with each layer completing successfully. The final line shows the image tagged as photo-api:v1.
Why rebuild instead of copying the image?
Building directly inside Minikube's Docker daemon is faster than loading a large image tarball. It avoids the overhead of exporting and importing the image between two Docker daemons.
Seeing 'Cannot connect to the Docker daemon'?
Make sure your Minikube cluster is running. Run minikube status to check. If it shows Stopped, start it again with minikube start.
Also confirm you ran eval $(minikube docker-env) in the same terminal window where you are building.
Help me troubleshoot my Docker connection to Minikube.
Create a deployment YAML
A Deployment is the Kubernetes resource that manages your containers. You write a YAML file declaring what you want (which image, how many copies, which port), and Kubernetes makes it happen.
- Create a new file called deployment.yaml in your photo-api project folder with the following content:
apiVersion: apps/v1
kind: Deployment
metadata:
name: photo-api
spec:
replicas: 1
selector:
matchLabels:
app: photo-api
template:
metadata:
labels:
app: photo-api
spec:
containers:
- name: photo-api
image: photo-api:v1
ports:
- containerPort: 3000
imagePullPolicy: Never
What does this YAML declare?
- replicas: 1 tells Kubernetes to run exactly one copy (one pod) of your container.
- image: photo-api:v1 points to the image you just built inside Minikube's Docker daemon.
- containerPort: 3000 declares the port your Express server listens on.
- imagePullPolicy: Never tells Kubernetes not to pull from Docker Hub. It uses the local image only.
- Save deployment.yaml.
- Confirm the file exists by running:
ls deployment.yaml
You should see deployment.yaml printed in the output.
File not found?
Make sure you are in the photo-api folder (the same directory that contains server.js and Dockerfile). Run ls to see all files in your current directory.
Help me find my deployment.yaml file.
Apply the deployment and expose it
With the YAML written, you can now tell Kubernetes to create the deployment. Then you will expose it as a Service so you can access it from outside the cluster.
- Apply your deployment to the cluster by running:
kubectl apply -f deployment.yaml
You should see deployment.apps/photo-api created confirming Kubernetes accepted your configuration.
- Check that your pod is running:
kubectl get pods
You should see one pod with a name starting with photo-api- and a STATUS of Running. This is your API container, now managed by Kubernetes.
Pod showing ImagePullBackOff or ErrImageNeverPull?
This means Kubernetes cannot find your image. The most likely cause is that you built the image in your local Docker daemon instead of Minikube's.
Run eval $(minikube docker-env) again in the same terminal, then rebuild with docker build -t photo-api:v1 . and delete the broken pod with kubectl delete pod --all. Kubernetes will create a new one automatically.
Help me fix my pod that won't start.
- Expose your deployment as a NodePort service so you can access it from your browser:
kubectl expose deployment photo-api --type=NodePort --port=3000 --name=photo-api-service
You should see service/photo-api-service exposed confirming the service was created.
What is a NodePort service?
A NodePort service opens a port on the cluster node and routes traffic to your pod. It is the simplest way to make a Kubernetes application accessible from outside the cluster.
On macOS with the Docker driver, Minikube runs inside a Docker container. The minikube service command handles the tunneling so you can reach it from your browser.
Before you run the next command, what do you think you will see when you open the API through the cluster? Will it look any different from when you ran it locally in Docker?
- Open your API in the browser through the Kubernetes cluster by running:
minikube service photo-api-service
A browser window opens with a JSON response from your API. The data is the same, but now it is served from inside the Kubernetes cluster. Notice the pod field in the root endpoint response shows a Kubernetes pod name instead of your machine's hostname.
- Append /api/photos to the URL in your browser to confirm the photography gallery data loads from the cluster.
You should see the full JSON array of photography data with titles, image URLs, and camera settings. Your API is now running on Kubernetes.
Browser not opening or connection refused?
If minikube service opens but the page shows a connection error, give the pod a few more seconds to start. Run kubectl get pods and wait until STATUS shows Running, then try the URL again.
If nothing opens at all, try running minikube service photo-api-service --url to get the URL printed in your terminal. Copy and paste it into your browser manually.
Help me access my Kubernetes service in the browser.
✔️ Awesome, I've got everything!
Great. Double check you have saved deployment.yaml and that your pod is running with kubectl get pods.
ⓧ I'd like to double check the full code
Here is the complete deployment.yaml file. Make sure yours matches exactly, including indentation:
apiVersion: apps/v1
kind: Deployment
metadata:
name: photo-api
spec:
replicas: 1
selector:
matchLabels:
app: photo-api
template:
metadata:
labels:
app: photo-api
spec:
containers:
- name: photo-api
image: photo-api:v1
ports:
- containerPort: 3000
imagePullPolicy: Never
Your photography API is no longer a lone container you babysit. Kubernetes is managing it, ready to keep it online. Next up, you will push Kubernetes to its limits by deliberately crashing your API and watching it self-heal.
Break It and Watch Kubernetes Heal
Your photography API is live on Kubernetes, but it has a single point of failure. Only one pod is serving all traffic right now.
What happens if that pod crashes? In this step, you will deliberately kill your only pod, feel the pain of downtime, then fix the problem by scaling to multiple replicas.
In this step, get ready to:
- Kill a pod and experience brief API downtime.
- Scale the deployment to 3 replicas for resilience.
- Kill another pod and confirm the API stays online.
Kill a pod and feel the pain
Right now your deployment has a single replica. If that one pod goes down, there is nothing left to serve requests.
- Get the name of your running pod by running this command:
kubectl get pods
You will see one pod listed with a name like photo-api-xxxxxxxxx-xxxxx. Copy that full name.
Before you delete this pod, what do you think will happen to your photography API? Will it stay online or go down?
- Delete the pod by running this command (replace the pod name with yours):
kubectl delete pod [[PODNAME="your-pod-name-from-above"]]
- Immediately try to hit your API by running:
curl $(minikube service photo-api-service --url)/api/photos
You will see a connection error or empty response. Your API is briefly unreachable because the only pod that was serving requests is gone.
Why did the API go down?
With only 1 replica, deleting the pod means zero pods are running to handle requests. Kubernetes immediately notices the gap and starts launching a replacement, but there is a brief window where nothing is serving traffic.
This is the weakness of a single-replica deployment. A real photography platform cannot afford even a few seconds of downtime every time a pod crashes.
- Wait a few seconds, then check your pods again:
kubectl get pods
Kubernetes has already launched a brand new pod to replace the one you killed. Its name is different, but the API is back online.
- Confirm the API is responding again by running:
curl $(minikube service photo-api-service --url)/api/photos
You should see the familiar JSON array of photo data. Kubernetes healed itself, but that brief downtime is unacceptable for a real platform.
Still seeing a connection error?
The replacement pod may still be starting up. Wait 5-10 seconds and try the curl command again. Run kubectl get pods and confirm the new pod shows Running in the STATUS column before retrying.
help me debug why my pod is not coming back after deletion
Scale up to 3 replicas
The fix is straightforward. Instead of running one copy of your API, run three. If one pod dies, the other two keep serving photographers' galleries while Kubernetes replaces it.
- Scale your deployment to 3 replicas by running:
kubectl scale deployment/photo-api --replicas=3
- Watch all 3 pods reach Running status by running:
kubectl get pods
You should see 3 pods listed, all showing Running in the STATUS column and 1/1 in the READY column.
What does scaling do?
Kubernetes creates two additional pods running the same photo-api:v1 image. The Service you created earlier automatically discovers all pods with the app: photo-api label and distributes traffic across them.
You did not need to update any configuration files or restart anything. Kubernetes handles the routing for you.
- Hit your API several times in a row and watch the X-Served-By header rotate across pods:
curl -s -D - $(minikube service photo-api-service --url)/api/photographers | grep X-Served-By
- Run the same command 3-4 more times.
Each time you run it, the X-Served-By header shows a different pod hostname. This is load balancing in action. Kubernetes is spreading your requests across all 3 replicas.
Seeing the same pod name every time?
Wait a few seconds between requests. The Service uses round-robin distribution, but rapid successive requests from the same client can sometimes land on the same pod. Run the command 5-6 times with a pause and you should see at least two different hostnames.
help me understand why my requests keep hitting the same pod
Kill a pod again (no pain this time)
Now for the real test. With 3 replicas running, what happens when one dies?
- Get your current pod names by running:
kubectl get pods
- Copy any one of the three pod names and delete it:
kubectl delete pod [[PODNAME2="one-of-your-three-pod-names"]]
Before you test the API, do you think it will respond this time? Or will you see the same downtime as before?
- Immediately hit your API by running:
curl $(minikube service photo-api-service --url)/api/photos
The API responds instantly. No downtime. The remaining 2 pods kept serving while Kubernetes spins up a replacement.
- Check your pods to see the replacement already launching:
kubectl get pods
You should see 3 pods again. One of them will have a very recent AGE (just a few seconds old). That is the replacement Kubernetes launched automatically to maintain your desired count of 3.
What is self-healing?
You told Kubernetes you want 3 replicas. It treats that as a contract. If any pod disappears for any reason, Kubernetes immediately launches a new one to restore the count. This is called self-healing.
In production, pods crash due to memory leaks, hardware failures, or bugs. With multiple replicas and self-healing, your users never notice.
Your photography platform is now resilient. Pods can crash and your API stays online. Next up, you will ship a brand new version of the API without dropping a single request.
Roll Out a New Version
Your photography API is now resilient. Three replicas keep it online even when a pod dies. But resilience is only half the story.
In production, you ship new features constantly. You need a way to deploy a new version of your API without taking it offline. Kubernetes solves this with rolling updates: it replaces pods one at a time, keeping the rest available to serve traffic throughout the process.
In this step, get ready to:
- Add a search feature to the API and build a v2 image.
- Trigger a zero-downtime rolling update across all 3 replicas.
- Practice rolling back to the previous version.
Add tag filtering to your API
Your v2 API will let users filter photos by tag (like ?tag=landscape). It also bumps the version number and adds health endpoints you will use later in the Secret Mission.
- In server.js, find this line near the top of the file:
const VERSION = "1.0";
- Replace it with these lines (bumping the version and adding a health state variable):
const VERSION = "2.0";
let healthy = true;
- Next, find the root endpoint (app.get("/", ...)) and locate the endpoints array inside it:
endpoints: ["/api/photos", "/api/photographers", "/api/photographers/:id"]
- Replace that array with this updated version that advertises the new filter feature:
endpoints: ["/api/photos", "/api/photos?tag=landscape", "/api/photographers", "/api/photographers/:id"]
- Now find the existing /healthz route near the bottom of the file:
app.get("/healthz", (req, res) => {
res.status(200).json({ status: "ok" });
});
- Replace it with this conditional version and add the /break endpoint directly below it:
app.get("/healthz", (req, res) => {
if (healthy) {
res.status(200).json({ status: "ok" });
} else {
res.status(500).json({ status: "unhealthy" });
}
});
app.get("/break", (req, res) => {
healthy = false;
res.json({ message: "Health check will now fail. Kubernetes will restart this pod." });
});
What do these health changes do?
The healthy variable acts as a kill switch. When it is true, /healthz returns 200. When something flips it to false, /healthz returns 500.
The /break endpoint flips that switch. You will use both of these in the Secret Mission to simulate a pod failing its health check.
- Now find the existing /api/photos route:
app.get("/api/photos", (req, res) => {
res.json(photos);
});
- Replace it with this version that supports filtering by tag and photographer:
// Filter photos by tag or photographer ID via query params
app.get("/api/photos", (req, res) => {
let results = photos;
if (req.query.tag) {
results = photos.filter(p => p.tags.includes(req.query.tag.toLowerCase()));
}
if (req.query.photographer) {
const photographerId = parseInt(req.query.photographer);
results = results.filter(p => p.photographerId === photographerId);
}
res.json(results);
});
What does this filtering code do?
When a request arrives at /api/photos?tag=landscape, the route filters the array to only include photos whose tags array contains "landscape".
The ?photographer=1 filter narrows results to a specific photographer's work. Both filters can combine, so ?tag=street&photographer=2 returns only Marcus Rivera's street photos.
- Save server.js.
- Build the updated image as photo-api:v2 by running this command:
docker build -t photo-api:v2 .
You should see each build step complete and a final line showing the image tagged as photo-api:v2.
Build failing?
- Make sure your terminal is still pointed to Minikube's Docker daemon. If you opened a new terminal tab, run eval $(minikube docker-env) again before building.
- Check that server.js has no syntax errors. A missing comma or bracket will cause the build to succeed but the container to crash on startup.
help me debug my Docker build for photo-api:v2
✔️ Awesome, I've got everything!
Great. Make sure you have saved server.js before moving on.
ⓧ I'd like to double check the full code
Your server.js is over 30 lines, so confirm each section is present in this order:
- Imports (express and os).
- Constants: PORT = 3000, VERSION = "2.0", let healthy = true.
- The photographers array (3 items).
- The photos array (6 items with tags).
- Middleware setting X-Served-By and X-API-Version headers.
- Root / endpoint with updated endpoints array including /api/photos?tag=landscape.
- The /api/photos route with tag and photographer query filtering.
- The /api/photos/:id, /api/photographers, and /api/photographers/:id routes (unchanged).
- Conditional /healthz endpoint checking the healthy variable.
- The /break endpoint that flips healthy to false.
- The app.listen call on port 3000.
Trigger the rolling update
Your v2 image is built. Now tell Kubernetes to swap the running v1 pods for v2 pods, one at a time, without any downtime.
- Trigger the rolling update by running this command:
kubectl set image deployment/photo-api photo-api=photo-api:v2
What does this command do?
This tells Kubernetes to update the photo-api container inside the photo-api deployment to use the photo-api:v2 image. Kubernetes doesn't restart all 3 pods at once. It terminates one pod, starts a new v2 pod, waits for it to be ready, then moves to the next.
- Watch the rollout progress by running:
kubectl rollout status deployment/photo-api
You should see a message ending with deployment "photo-api" successfully rolled out. All 3 pods are now running your v2 code.
- Confirm all pods are running by checking their status:
kubectl get pods
All 3 pods should show Running status with a recent AGE value (seconds or minutes old), proving they were freshly created for v2.
Pods stuck in CrashLoopBackOff?
This means your v2 container is crashing on startup. Check the logs for a syntax error:
kubectl logs deployment/photo-api
- Look for a JavaScript error in the output. A missing bracket, comma, or parenthesis in server.js is the most common cause.
- Fix the syntax error, rebuild with docker build -t photo-api:v2 ., then run kubectl rollout restart deployment/photo-api to pick up the new image.
help me fix CrashLoopBackOff after rolling update to photo-api:v2
Verify the new feature and practice a rollback
Your rolling update is complete. Before you predict what happens next, think about what the tag filter should return for landscape photos.
- Open the photography API in your browser by running:
minikube service photo-api-service
- In your browser, add /api/photos?tag=landscape to the end of the URL.
How many photos do you expect to see? The photos array has two entries tagged landscape: "Golden Hour Summit" and "Forest Mist". You should see exactly those two in the JSON response.
- Also check the response headers in your terminal (the minikube service output shows the URL). Run this curl command against that URL:
curl -s -D - $(minikube service photo-api-service --url)/api/photos?tag=landscape
Look for X-API-Version: 2.0 in the headers. This confirms the response is coming from a v2 pod.
Why check the version header?
During a rolling update, some requests might still hit old pods. The X-API-Version header lets you confirm that all pods have fully transitioned to v2. If you refresh several times and always see 2.0, the rollout is truly complete.
Now imagine you shipped a bug in v2. Kubernetes lets you undo a deployment with a single command.
- Roll back to v1 by running:
kubectl rollout undo deployment/photo-api
- Check the rollback completed by running:
kubectl rollout status deployment/photo-api
You should see deployment "photo-api" successfully rolled out again. Kubernetes replaced the v2 pods with v1 pods using the same rolling strategy.
- Verify you are back on v1 by curling the API:
curl -s -D - $(minikube service photo-api-service --url)/api/photos?tag=landscape
This time you should see X-API-Version: 1.0 in the headers, and the response body returns ALL photos (v1 has no filtering).
- Now re-deploy v2 so you are back on the latest version for the rest of the project:
kubectl set image deployment/photo-api photo-api=photo-api:v2
- Confirm the rollout finishes:
kubectl rollout status deployment/photo-api
What did you just prove?
You deployed a new feature, verified it worked, rolled it back in seconds, then re-deployed. In a real production environment this same workflow lets you ship confidently. If something breaks, kubectl rollout undo gets you back to the last working version in under a minute.
You just shipped a new feature to a live API without dropping a single request. That is the exact production deployment pattern used at companies running real services on Kubernetes. Next up, the Secret Mission challenges you to add health probes so Kubernetes can detect broken pods automatically.
Secret mission
Add Health Checks (Liveness and Readiness Probes)
Your API auto-heals when pods disappear, but what about a pod that's alive yet broken? Right now, Kubernetes has no way to detect a pod returning 500 errors on every request. Add liveness and readiness probes so Kubernetes can detect truly unhealthy pods and replace them automatically.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Minikube cluster (local Kubernetes cluster running inside Docker).
- Kubernetes Deployment and Service (photo-api, photo-api-service).
- Docker images (photo-api:v1, photo-api:v2) inside Minikube's Docker daemon.
- Project folder on your Desktop (photo-api).
- Docker Desktop application.
- Minikube and kubectl CLI tools (installed via Homebrew).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment more with Kubernetes commands.
- Your Minikube cluster stays running and you can access it any time with kubectl commands.
- Docker Desktop continues running in the background and uses some memory.
- Your project files remain on your Desktop for future edits.
Pause - I'll come back to this later
Shut down the Minikube cluster to free up memory and CPU while keeping all your files and configuration intact.
- Stop the Minikube cluster by running:
- This preserves your cluster state so you can resume later with minikube start.
minikube stop
- Quit Docker Desktop by clicking the Docker icon in your menu bar and selecting Quit Docker Desktop.
- Your project files stay on your Desktop untouched.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the cluster, images, project files, and optionally the tools themselves.
- Delete the Minikube cluster (removes all deployments, services, and Docker images inside it) by running:
minikube delete
- Delete your project folder by running:
rm -rf ~/Desktop/photo-api
- Uninstall Minikube and kubectl by running:
brew uninstall minikube kubectl
- Uninstall Docker Desktop by opening the Docker Desktop dashboard, clicking the Troubleshoot icon (top-right), selecting Uninstall, and confirming.
Nice Work!
Nice Work!
You just deployed a production-grade photography gallery API on a local Kubernetes cluster. Your API auto-heals, load-balances across pods, and ships new versions without dropping a single request.
You've learned how to:
- Build a REST API with Express.js, containerize it with Docker, and deploy it to a local Kubernetes cluster using Minikube.
- Prove Kubernetes self-healing by killing pods, scaling to multiple replicas, and watching load balancing distribute traffic across pods.
- Ship a new API version with a zero-downtime rolling update and practice a rollback to revert a bad deploy.
- Secret Mission: Add liveness and readiness probes so Kubernetes can detect unhealthy pods and automatically restart them.
Ready to quiz yourself?