Deploy Your First App to Kubernetes
Build and deploy a custom nginx container to a local Kubernetes cluster.
Introduction
30 Second Summary
Every time you use an app on your phone or visit a website, there's a system behind the scenes making sure that app stays running, handles traffic, and recovers from crashes. That system is often Kubernetes.
In this project, you will build a custom nginx container image and deploy it to a local Kubernetes cluster running on Minikube. You'll configure it with real-world patterns like ConfigMaps, Secrets, rolling updates, and rollbacks.
What You'll Build
A custom nginx web server deployed to a local Kubernetes cluster, exposed via a NodePort Service and configured with environment-specific settings injected through ConfigMaps and Secrets.
By the end of this project, you'll have:
- A custom Docker image built from a Dockerfile and loaded into a local Kubernetes cluster.
- A running Deployment exposed via a NodePort Service, configured with a ConfigMap and Secret.
- A completed rolling update to a new version and a successful rollback to the previous version.
- Secret Mission: Add resource limits, liveness/readiness probes, and a sidecar logging container to production-harden your deployment.
Are there any prerequisites?
This project assumes you have Windows 11 with Docker Desktop installed, Ubuntu running in WSL2, and Minikube already installed. Step 1 verifies everything is working before you start building.
This project covers two of the five CKA exam domains (40% of the exam weight): Cluster Architecture, Installation & Configuration (25%) and Workloads & Scheduling (15%).
Verify Your Environment and Start the Cluster
Before you can deploy anything to Kubernetes, you need a working cluster. A cluster is a set of machines (in our case, a single virtual node) that run your containerized applications.
In this step, you will verify that Docker and Minikube are correctly accessible from your WSL2 terminal, set up the kubectl command, and start a local Kubernetes cluster.
In this step, get ready to:
- Verify Docker and Minikube are accessible from your Ubuntu WSL2 terminal.
- Set up the kubectl alias and start your Minikube cluster.
- Confirm the cluster is healthy with all system pods running.
Verify Docker and Minikube are accessible from WSL2
Docker Desktop runs on Windows, but your project work happens inside Ubuntu on WSL2. You need to confirm that Docker Desktop's WSL2 integration is working so the docker command is available inside your Linux terminal.
- Press the Windows key to open your search bar.
- Type Ubuntu and press Enter to open your Ubuntu WSL2 terminal.
- Confirm Docker Desktop is reachable from WSL2 by running this command:
docker version
✔️ I see Client and Server version info
Docker Desktop is properly integrated with your WSL2 distribution. You should see both a Client section and a Server section in the output.
ⓧ Cannot connect to the Docker daemon
This means Docker Desktop is not running or WSL2 integration is not enabled for your Ubuntu distribution. Follow these steps to fix it:
- Make sure Docker Desktop is running on Windows. Look for the Docker whale icon in your system tray.
- In Docker Desktop, open Settings, then go to Resources, then WSL Integration.
- Enable integration for your Ubuntu distribution and click Apply & Restart.
- Close and reopen your Ubuntu terminal, then run docker version again.
- Confirm Minikube is the Linux binary (not a Windows .exe) by running this command:
minikube version
✔️ I see a minikube version number
Minikube is correctly installed as a Linux binary inside WSL2. You should see output like minikube version: v1.38.1 (your version may differ).
ⓧ I see an error or the command is not found
If you see an error mentioning a Windows executable or .exe path, you are accidentally running the Windows version of Minikube inside WSL2. You need the Linux binary instead.
- Check which binary is being found by running:
which minikube
If the path contains /mnt/c/, you are using the Windows binary. Install the Linux version of Minikube inside WSL2 by following the official Minikube installation guide for Linux.
Now you need to set up kubectl, the command-line tool for interacting with Kubernetes clusters. Minikube bundles its own kubectl so you do not need to install it separately.
What is kubectl?
kubectl is the Kubernetes command-line client. You use it to create, inspect, update, and delete resources in your cluster. Minikube includes a matching version automatically, so it stays compatible with your cluster.
- Create the kubectl alias in your current terminal session by running this command:
alias kubectl="minikube kubectl --"
- Persist the alias so it is available every time you open a new terminal by running:
echo 'alias kubectl="minikube kubectl --"' >> ~/.bashrc
What does this do?
The first command creates a shortcut so that typing kubectl in your terminal actually runs minikube kubectl -- behind the scenes. The second command appends that alias to your ~/.bashrc file, which runs every time you open a new shell.
Alias not working?
- Make sure you used straight double quotes, not curly/smart quotes that some text editors insert automatically.
- If you accidentally ran the command multiple times, it is harmless. The alias line just appears more than once in ~/.bashrc.
Ask for help: My kubectl alias is not working in WSL2. How do I fix it?
Start the Minikube cluster with the Docker driver
With Docker accessible and kubectl aliased, you are ready to spin up a local Kubernetes cluster. Minikube creates a single-node cluster inside a Docker container on your machine.
- Start your Minikube cluster by running this command:
minikube start --driver=docker
What does this command do?
This tells Minikube to create a local Kubernetes cluster using Docker as the virtualization driver. Minikube downloads Kubernetes components (API server, etcd, scheduler, and more) into a Docker container.
On first run, this downloads several hundred megabytes of container images. It may take a few minutes depending on your internet connection.
- Wait for the command to finish. You will see status messages ending with a line like "Done! kubectl is now configured to use minikube cluster".
- Verify the node is ready by running:
kubectl get nodes
You should see a single node named minikube with status Ready.
- See the control plane URL by running:
kubectl cluster-info
You should see output showing the Kubernetes control plane and CoreDNS running at local addresses.
Cluster failed to start?
- Make sure Docker Desktop is running and not in a paused state. Check the Docker whale icon in your Windows system tray.
- If you see an error about insufficient resources, close other memory-heavy applications. Minikube needs at least 2 CPUs and 2GB of free memory.
- If the error mentions a timeout waiting for the cluster, try running minikube delete followed by minikube start --driver=docker to start fresh.
Ask for help: My minikube start command failed. How do I fix this?
Verify the cluster is healthy
A healthy Kubernetes cluster runs several system pods that handle networking, DNS, and the API server itself. Checking these confirms your cluster is fully operational.
- View all system pods across every namespace by running:
kubectl get pods -A
You should see pods like coredns, etcd-minikube, kube-apiserver-minikube, kube-scheduler-minikube, and others, all showing Running status.
Some pods are not Running?
- If pods show Pending or ContainerCreating, wait 30 seconds and run kubectl get pods -A again. Pods sometimes take a moment to initialize on first boot.
- If a pod shows CrashLoopBackOff, your cluster may have hit a resource limit. Try minikube delete then minikube start --driver=docker with Docker Desktop running cleanly.
Ask for help: Some of my kube-system pods are not in Running status. What should I do?
Your cluster is up and running. Next up, you will build a custom container image and load it into this cluster.
Build Your Custom Container Image
Your Minikube cluster is running and healthy. Now you need something to actually deploy to it.
In this step, you'll build a custom container image from a Dockerfile. This image packages a branded landing page with a custom nginx configuration into a single deployable unit that your cluster can run.
In this step, get ready to:
- Create your project files (HTML page, nginx config, and Dockerfile).
- Build a Docker image from the Dockerfile.
- Load the image into Minikube so your cluster can use it.
Create the project directory and files
Your custom nginx image needs two things: an HTML page to serve, and a configuration file that tells nginx how to behave. You'll create a project folder to hold these files.
- Create your project directory and navigate into it by running these commands:
mkdir -p ~/k8s-nginx-project/html && cd ~/k8s-nginx-project
What does this command do?
The -p flag creates the full path, including the nested html subdirectory inside k8s-nginx-project. Then cd moves you into the project root so all future commands run from there.
- Confirm the directory was created by running:
ls
You should see the html folder listed in the output.
- Create html/index.html by opening a new file in nano:
nano html/index.html
- Paste the following content into the editor:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>K8s App v1</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #1a1a2e;
color: #e0e0e0;
}
.container {
text-align: center;
padding: 2rem;
border: 2px solid #0f3460;
border-radius: 12px;
background-color: #16213e;
}
h1 { color: #00d4ff; }
.version { font-size: 1.5rem; color: #4ecca3; margin-top: 1rem; }
</style>
</head>
<body>
<div class="container">
<h1>Hello from Kubernetes!</h1>
<p>This page is served by a custom nginx container running in Minikube.</p>
<p class="version">Version: v1</p>
</div>
</body>
</html>
What does this page do?
This is a simple landing page that displays a greeting and a version number. The version text (v1) is important. Later, you'll update it to v2 to trigger a rolling update and visually confirm which version is running.
- Save the file by pressing **Ctrl+O**, then **Enter** to confirm the filename, then **Ctrl+X** to exit nano.
- Confirm the file was saved by running:
ls html/
You should see index.html listed in the output.
Don't see index.html?
Make sure you pressed **Ctrl+O** (the letter O, not zero) to write the file before pressing **Ctrl+X** to exit. If nano asks for a filename, press **Enter** to accept the default.
If the html directory is missing, re-run the mkdir -p command from above, then try creating the file again.
Help me troubleshoot my file creation in nano.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>K8s App v1</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #1a1a2e;
color: #e0e0e0;
}
.container {
text-align: center;
padding: 2rem;
border: 2px solid #0f3460;
border-radius: 12px;
background-color: #16213e;
}
h1 { color: #00d4ff; }
.version { font-size: 1.5rem; color: #4ecca3; margin-top: 1rem; }
</style>
</head>
<body>
<div class="container">
<h1>Hello from Kubernetes!</h1>
<p>This page is served by a custom nginx container running in Minikube.</p>
<p class="version">Version: v1</p>
</div>
</body>
</html>
- Create nginx.conf by opening a new file in nano:
nano nginx.conf
- Paste the following content into the editor:
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
add_header X-Served-By "k8s-nginx";
}
location /health {
return 200 'healthy';
add_header Content-Type text/plain;
}
}
What does this configuration do?
- The / location serves your HTML page and adds a custom header (X-Served-By: k8s-nginx) so you can confirm requests are hitting your container.
- The /health endpoint returns a plain-text "healthy" response. Kubernetes uses endpoints like this for liveness and readiness probes to check if your app is alive.
- Save the file by pressing **Ctrl+O**, then **Enter**, then **Ctrl+X** to exit nano.
- Confirm the file exists by running:
ls
You should see both html and nginx.conf listed in the output.
Don't see nginx.conf?
Confirm you're in the ~/k8s-nginx-project directory by running pwd. If you're elsewhere, run cd ~/k8s-nginx-project and try creating the file again.
Help me figure out where my nginx.conf file ended up.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
add_header X-Served-By "k8s-nginx";
}
location /health {
return 200 'healthy';
add_header Content-Type text/plain;
}
}
Write the Dockerfile
A Dockerfile is a recipe that tells Docker how to build your image. It starts from a base image and layers your custom files on top.
- Create a Dockerfile in the project root by running:
nano Dockerfile
- Paste the following content into the editor:
FROM nginx:1.30.3-alpine
COPY html/index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
What does this Dockerfile do?
- FROM nginx:1.30.3-alpine starts from the official nginx image pinned to version 1.30.3. The Alpine variant is only ~5MB, making builds fast.
- The two COPY instructions layer your custom HTML page and nginx config into the image, replacing the defaults.
- EXPOSE 80 documents that the container listens on port 80. It doesn't publish the port by itself. Kubernetes handles that through Services.
Why pin to nginx:1.30.3-alpine?
Using latest or stable tags means your image could change unexpectedly when the tag moves. Pinning to 1.30.3-alpine guarantees the same base every time you build. Reproducibility matters in Kubernetes, because you need to know exactly what version you're rolling back to.
- Save the file by pressing **Ctrl+O**, then **Enter**, then **Ctrl+X** to exit nano.
- Confirm all project files are in place by running:
ls
You should see Dockerfile, html, and nginx.conf in the output.
Missing the Dockerfile?
The filename must be exactly Dockerfile with a capital D and no file extension. If you accidentally named it dockerfile or Dockerfile.txt, rename it with mv (e.g. mv dockerfile Dockerfile).
Help me fix my Dockerfile naming issue.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
FROM nginx:1.30.3-alpine
COPY html/index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
Build the image and load it into Minikube
With all three files ready, you can build the image. Then you need to load it into Minikube's container runtime so your cluster can actually use it.
- Build the Docker image by running:
docker build -t k8s-nginx:v1 .
What does this command do?
The -t k8s-nginx:v1 flag tags the image with the name k8s-nginx and version v1. The . at the end tells Docker to use the current directory as the build context (where to find the Dockerfile and files it references).
You should see Docker pull the nginx:1.30.3-alpine base image (on first run), then copy your files and tag the result. The final line shows the image ID.
Seeing a build error?
- If you see an error about missing files, confirm you're in the ~/k8s-nginx-project directory by running pwd.
- If Docker says it cannot connect, make sure Docker Desktop is running on your Windows host. You can check with docker version.
- If the Dockerfile isn't found, check the filename is exactly Dockerfile (capital D, no extension).
Help me debug my Docker build error.
- Load the image into Minikube's container runtime by running:
minikube image load k8s-nginx:v1
Why load the image into Minikube?
Docker built the image on your local Docker daemon, but Minikube runs its own container runtime inside a separate environment. The minikube image load command copies your image into Minikube's runtime so Kubernetes Pods can access it without pulling from a remote registry.
This command may take a few seconds. Wait for it to return to the prompt before continuing.
- Verify the image is available in Minikube by running:
minikube image list | grep k8s-nginx
You should see docker.io/library/k8s-nginx:v1 in the output. This confirms your image is loaded and ready for Kubernetes to use.
Why does it say docker.io/library/?
Minikube automatically adds the docker.io/library/ prefix to locally loaded images. This is normal. Kubernetes will still find the image by its short name (k8s-nginx:v1) when you set imagePullPolicy: Never in your Deployment manifest.
Don't see k8s-nginx in the image list?
- Make sure the docker build step completed successfully first. Run docker images | grep k8s-nginx to check that the image exists locally.
- Confirm your Minikube cluster is still running with minikube status. If it stopped, restart it with minikube start.
Help me get my image loaded into Minikube.
Your custom container image is built and loaded into the cluster. Next up, you'll write Kubernetes manifests to deploy it and expose it as a running service.
Deploy to Kubernetes with a Service
Your container image is built and loaded into the cluster. Now it's time to run it.
In Kubernetes, you don't run containers directly. Instead, you define a Deployment that tells Kubernetes how many copies (replicas) of your container to run, and a Service that gives those replicas a stable network address you can reach from outside the cluster.
In this step, get ready to:
- Write a Deployment manifest that runs two replicas of your nginx container.
- Write a Service manifest that exposes your Deployment via a NodePort.
- Apply both manifests and confirm the app is reachable with curl.
Write the Deployment manifest
A Deployment manifest is a YAML file that declares the desired state of your application. Kubernetes reads it and makes sure the right number of Pods are always running.
- Create a file called deployment.yaml in your ~/k8s-nginx-project folder by running this command:
nano deployment.yaml
- Paste the following manifest into the file:
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-nginx-deployment
labels:
app: k8s-nginx
spec:
replicas: 2
selector:
matchLabels:
app: k8s-nginx
template:
metadata:
labels:
app: k8s-nginx
spec:
containers:
- name: k8s-nginx
image: k8s-nginx:v1
imagePullPolicy: Never
ports:
- containerPort: 80
What does this manifest do?
This file tells Kubernetes to create a Deployment named k8s-nginx-deployment that keeps 2 replicas of your container running at all times.
- replicas: 2 means Kubernetes will always maintain two Pods running your container. If one crashes, Kubernetes automatically replaces it.
- selector and labels connect the Deployment to its Pods. The selector says "manage any Pod with the label app: k8s-nginx".
- imagePullPolicy: Never tells Kubernetes to use the local image you loaded with minikube image load instead of trying to pull from a remote registry.
- containerPort: 80 declares that the container listens on port 80.
- Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
- Confirm the file exists by running:
ls deployment.yaml
You should see deployment.yaml in the output.
File not showing up?
- Make sure you are in the ~/k8s-nginx-project directory. Run cd ~/k8s-nginx-project to confirm.
- If nano showed an error, check that you typed nano deployment.yaml exactly.
Help me troubleshoot my deployment.yaml file creation
Write the Service manifest
A NodePort Service opens a port on the cluster node so you can reach your Pods from outside Kubernetes. Without it, your containers are running but invisible to the outside world.
- Create a file called service.yaml by running:
nano service.yaml
- Paste the following manifest into the file:
apiVersion: v1
kind: Service
metadata:
name: k8s-nginx-service
spec:
type: NodePort
selector:
app: k8s-nginx
ports:
- protocol: TCP
port: 80
targetPort: 80
What does this Service do?
This file creates a Service named k8s-nginx-service that routes traffic to any Pod with the label app: k8s-nginx.
- type: NodePort exposes the Service on a high-numbered port on the cluster node. Minikube gives you a URL to reach it.
- selector: app: k8s-nginx is the glue between the Service and the Deployment. It matches the labels you defined in your Deployment's Pod template.
- port: 80 is the port the Service listens on. targetPort: 80 is where traffic gets sent inside the container.
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
- Confirm both manifest files exist by running:
ls *.yaml
You should see deployment.yaml and service.yaml in the output.
Only seeing one file?
- Double-check you saved service.yaml in the same directory (~/k8s-nginx-project). Run pwd to verify your current location.
- If you accidentally saved it elsewhere, run nano service.yaml again and re-paste the content.
Help me find my missing YAML file
Apply the manifests and verify your app is running
With both manifests ready, you can tell Kubernetes to create the resources. The kubectl apply command sends your YAML to the cluster, and Kubernetes does the rest.
- Apply the Deployment by running:
kubectl apply -f deployment.yaml
- Apply the Service by running:
kubectl apply -f service.yaml
- Check that the Deployment has 2/2 replicas ready by running:
kubectl get deployments
You should see k8s-nginx-deployment with 2/2 in the READY column.
- Confirm both Pods are Running by running:
kubectl get pods
You should see two pods with names starting with k8s-nginx-deployment- both showing Running status.
Pods stuck in ErrImagePull or ImagePullBackOff?
This means Kubernetes is trying to pull the image from a remote registry instead of using your local image.
- Verify your deployment.yaml includes imagePullPolicy: Never under the container spec. Without it, Kubernetes ignores the local image.
- Verify the image name matches exactly: k8s-nginx:v1. Run minikube image list | grep k8s-nginx to confirm the image is loaded.
- If you fix the YAML, re-apply with kubectl apply -f deployment.yaml.
Help me fix my Pods that are stuck in ErrImagePull
- Get the URL for your Service by running:
minikube service k8s-nginx-service --url
This prints a URL like http://192.168.49.2:30XXX. The exact port number will vary.
- Curl that URL to see your custom page by running:
curl $(minikube service k8s-nginx-service --url)
You should see your HTML page content in the terminal output, including the text Hello from Kubernetes! and Version: v1.
Curl returning an error or no output?
- Run kubectl get service k8s-nginx-service and confirm the TYPE is NodePort. If it shows ClusterIP, check your service.yaml has type: NodePort under spec.
- Check that your Pods are actually running with kubectl get pods. If they show CrashLoopBackOff, the container is crashing. Run kubectl logs followed by a pod name to see the error.
- If the minikube service command hangs, press Ctrl+C and try running the two commands separately (get the URL first, then curl it).
Help me troubleshoot why I can't reach my Kubernetes service
✔️ Awesome, I've got everything!
Great. Double check you have saved both deployment.yaml and service.yaml.
ⓧ I'd like to double check the full code
Here is the complete deployment.yaml for this step:
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-nginx-deployment
labels:
app: k8s-nginx
spec:
replicas: 2
selector:
matchLabels:
app: k8s-nginx
template:
metadata:
labels:
app: k8s-nginx
spec:
containers:
- name: k8s-nginx
image: k8s-nginx:v1
imagePullPolicy: Never
ports:
- containerPort: 80
And the complete service.yaml:
apiVersion: v1
kind: Service
metadata:
name: k8s-nginx-service
spec:
type: NodePort
selector:
app: k8s-nginx
ports:
- protocol: TCP
port: 80
targetPort: 80
Your app is live inside the cluster and reachable from your terminal. Next up, you'll inject configuration and secrets to make the deployment production-ready.
Configure with ConfigMaps and Secrets
Your deployment is running and accessible through a NodePort Service. Right now, the nginx configuration is baked directly into the container image.
In this step, you'll externalize that configuration using two Kubernetes primitives: ConfigMaps for non-sensitive settings, and Secrets for credentials. This separation means you can update configuration without rebuilding your image.
In this step, get ready to:
- Create a ConfigMap to manage your nginx configuration as a Kubernetes resource.
- Create a Secret to store sensitive credentials that get injected into your pods.
- Update your Deployment to wire in both the ConfigMap and Secret.
Create a ConfigMap from a YAML manifest
A ConfigMap is a Kubernetes object that stores non-sensitive configuration data as key-value pairs. Instead of baking your nginx config into the image, you'll store it in the cluster and mount it as a file inside the container.
- Create a file called configmap.yaml in your k8s-nginx-project folder with the following content:
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
default.conf: |
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
add_header X-Served-By "k8s-nginx";
}
location /health {
return 200 'healthy';
add_header Content-Type text/plain;
}
}
What does this YAML do?
- kind: ConfigMap tells Kubernetes this is a configuration data object, not a workload or service.
- The data section holds a key called default.conf whose value is a full nginx server block.
- The | (pipe) character tells YAML to preserve the multi-line string exactly as written, including indentation.
- The nginx config adds a custom header X-Served-By: k8s-nginx and a /health endpoint that returns healthy.
- Save configmap.yaml.
- Apply the ConfigMap to your cluster by running this command:
kubectl apply -f configmap.yaml
You should see configmap/nginx-config created in the output.
Seeing a YAML parsing error?
- Check that the | pipe character appears immediately after default.conf: with a space between the colon and the pipe.
- Make sure the indentation inside the data block uses spaces, not tabs. YAML does not allow tabs.
- Confirm the server block is indented 4 spaces deeper than default.conf:.
Help me fix my configmap.yaml YAML parsing error
✔️ Awesome, I've got everything!
Great. Double check you've saved configmap.yaml and that the apply command confirmed creation.
ⓧ I'd like to double check the full code
apiVersion: v1
kind: ConfigMap
metadata:
name: nginx-config
data:
default.conf: |
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
add_header X-Served-By "k8s-nginx";
}
location /health {
return 200 'healthy';
add_header Content-Type text/plain;
}
}
Create a Secret imperatively
A Secret stores sensitive data like API keys and passwords. Kubernetes base64-encodes Secret values and restricts access more tightly than ConfigMaps.
You'll create this Secret imperatively (directly from the command line) instead of writing a manifest file. This avoids committing plain-text credentials to version control.
- Create the Secret by running this command:
kubectl create secret generic nginx-secret --from-literal=API_KEY=supersecretkey123 --from-literal=APP_ENV=production
You should see secret/nginx-secret created in the output.
What does this command do?
- kubectl create secret generic creates a generic-type Secret (as opposed to TLS or Docker registry types).
- nginx-secret is the name other Kubernetes resources will use to reference this Secret.
- Each --from-literal flag adds a key-value pair. Here you're storing an API_KEY and an APP_ENV value.
- Verify the Secret's stored data by running:
kubectl get secret nginx-secret -o jsonpath='{.data}'
You should see the two keys with base64-encoded values.
- Decode the API_KEY value to confirm it stored correctly by running:
kubectl get secret nginx-secret -o jsonpath='{.data.API_KEY}' | base64 --decode
You should see supersecretkey123 printed in your terminal.
Seeing 'already exists' error?
If you ran the create command before, delete the existing Secret first with kubectl delete secret nginx-secret and then re-run the create command.
Help me fix my Secret creation issue
Update the Deployment to use the ConfigMap and Secret
The ConfigMap and Secret exist in the cluster, but your pods don't know about them yet. You need to update your Deployment manifest to mount the ConfigMap as a file and inject the Secret as environment variables.
- Open deployment.yaml and replace its entire contents with the following:
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-nginx-deployment
labels:
app: k8s-nginx
spec:
replicas: 2
selector:
matchLabels:
app: k8s-nginx
template:
metadata:
labels:
app: k8s-nginx
spec:
containers:
- name: k8s-nginx
image: k8s-nginx:v1
imagePullPolicy: Never
ports:
- containerPort: 80
envFrom:
- secretRef:
name: nginx-secret
volumeMounts:
- name: nginx-config-volume
mountPath: /etc/nginx/conf.d
volumes:
- name: nginx-config-volume
configMap:
name: nginx-config
What changed in this Deployment?
- envFrom with secretRef: nginx-secret injects every key in the Secret as an environment variable inside the container.
- volumeMounts mounts the volume named nginx-config-volume at /etc/nginx/conf.d inside the container, overriding the baked-in config.
- The volumes section at the pod level defines nginx-config-volume as sourced from the nginx-config ConfigMap.
Together, these three additions wire external configuration into the running pods without changing the container image.
Why volumes instead of environment variables for the config?
Nginx reads its configuration from files on disk, not from environment variables. Mounting the ConfigMap as a volume at /etc/nginx/conf.d places the default.conf key as a real file that nginx loads on startup.
The Secret uses environment variables because application code typically reads credentials from env. Both patterns (volume mounts and env injection) are tested on the CKA exam.
- Save deployment.yaml.
- Apply the updated Deployment by running:
kubectl apply -f deployment.yaml
- Wait for the pods to restart, then confirm both replicas are running:
kubectl get pods
Both pods should show Running status with 1/1 ready.
- Test that the custom header is present by running:
curl -I $(minikube service k8s-nginx-service --url)
Look for X-Served-By: k8s-nginx in the response headers.
- Test the health endpoint by running:
curl $(minikube service k8s-nginx-service --url)/health
You should see healthy printed in the terminal.
- Verify the Secret was injected as an environment variable inside the pod by running:
kubectl exec deploy/k8s-nginx-deployment -- env | grep API_KEY
You should see API_KEY=supersecretkey123 in the output.
Pods stuck in CrashLoopBackOff?
- Run kubectl describe pod followed by the pod name to check the Events section for error details.
- If you see a volume mount error, confirm the ConfigMap exists with kubectl get configmap nginx-config. The ConfigMap must exist before the pods start.
- Check your YAML indentation in deployment.yaml. The volumes key must be at the same level as containers (both under spec of the pod template).
Help me fix my CrashLoopBackOff pods after adding ConfigMap and Secret
✔️ Awesome, I've got everything!
Great. Double check you've saved deployment.yaml and confirmed all three verifications pass: custom header, health endpoint, and environment variable.
ⓧ I'd like to double check the full code
apiVersion: apps/v1
kind: Deployment
metadata:
name: k8s-nginx-deployment
labels:
app: k8s-nginx
spec:
replicas: 2
selector:
matchLabels:
app: k8s-nginx
template:
metadata:
labels:
app: k8s-nginx
spec:
containers:
- name: k8s-nginx
image: k8s-nginx:v1
imagePullPolicy: Never
ports:
- containerPort: 80
envFrom:
- secretRef:
name: nginx-secret
volumeMounts:
- name: nginx-config-volume
mountPath: /etc/nginx/conf.d
volumes:
- name: nginx-config-volume
configMap:
name: nginx-config
Your deployment now pulls its configuration from the cluster instead of the image. Next up, you'll push a v2 update and watch Kubernetes replace your pods one by one with zero downtime.
Perform Rolling Updates and Rollbacks
Your deployment is running with a ConfigMap-driven nginx config and Secret-based environment variables. Now you need to answer a critical question: how do you deploy a new version of your app without taking it offline?
Kubernetes solves this with rolling updates. Instead of killing all pods and starting fresh, Kubernetes replaces pods one at a time. If something goes wrong, you can instantly roll back to the previous version with a single command.
In this step, get ready to:
- Build a v2 container image with updated HTML content and deploy it via a rolling update.
- Watch the rolling update replace pods one by one and verify the new version is live.
- Roll back to v1 with a single command and confirm the original content is restored.
Build and deploy a v2 image
To simulate a real-world deployment, you need a new version of your app that looks visibly different from v1. You will update the HTML, build a new image, and trigger the update imperatively.
- In your text editor, open html/index.html inside the k8s-nginx-project folder.
- Find this line in the <title> tag:
<title>K8s App v1</title>
- Change v1 to v2 so the line reads:
<title>K8s App v2</title>
- Find the <h1> tag:
<h1>Hello from Kubernetes!</h1>
- Change it to:
<h1>Kubernetes v2 - Updated!</h1>
- Find the version paragraph:
<p class="version">Version: v1</p>
- Change it to:
<p class="version">Version: v2</p>
- Save html/index.html.
Why change the heading and version text?
When you curl the service later, you need to instantly tell whether v1 or v2 is running. Making both the heading and version string different gives you two visual signals that the update worked.
- Build the v2 image by running this command:
docker build -t k8s-nginx:v2 .
- Load the v2 image into Minikube's container runtime by running:
minikube image load k8s-nginx:v2
- Verify the v2 image is available by running:
minikube image list | grep k8s-nginx
You should see both docker.io/library/k8s-nginx:v1 and docker.io/library/k8s-nginx:v2 in the output.
Only see v1 in the image list?
Make sure you ran docker build from the k8s-nginx-project directory (where your Dockerfile lives). Also confirm you tagged the image as k8s-nginx:v2 (not k8s-nginx:v1 again).
help me troubleshoot why my v2 image is not showing in minikube image list
Now trigger the rolling update. This command tells Kubernetes to update the container image for your deployment without editing the YAML file on disk.
- Start the rolling update by running:
kubectl set image deployment/k8s-nginx-deployment k8s-nginx=k8s-nginx:v2
What does this command do?
kubectl set image is an imperative command that updates the container image for a running deployment. The format is deployment/NAME CONTAINER=IMAGE.
This approach matches how the CKA exam works. The exam is CLI-driven and time-pressured, so knowing imperative commands is essential.
Watch the rolling update
Kubernetes does not swap all pods at once. It terminates one pod, starts a new one with the v2 image, waits for it to be ready, then moves to the next.
- Watch the rollout progress by running:
kubectl rollout status deployment/k8s-nginx-deployment
You should see a message ending with deployment "k8s-nginx-deployment" successfully rolled out.
Watch pods in real time
If you want to see pods being replaced live, open a second WSL2 terminal and run:
kubectl get pods -w
The -w flag watches for changes. You will see old pods terminating and new pods starting. Press Ctrl+C to stop watching.
- Verify the v2 content is live by curling the service:
curl $(minikube service k8s-nginx-service --url)
You should see your updated HTML with Kubernetes v2 - Updated! and Version: v2 in the response.
Still seeing v1 content?
Run kubectl get pods to check whether the old pods are still running. If the rollout has not finished, wait a moment and curl again.
Also confirm the image was loaded correctly with minikube image list | grep k8s-nginx. Both v1 and v2 tags should appear.
help me debug why my Kubernetes rolling update is still showing v1 content
- Check the rollout history by running:
kubectl rollout history deployment/k8s-nginx-deployment
What does rollout history show?
This command lists all recorded revisions of your deployment. You should see at least two revisions: revision 1 (your original v1 deployment) and revision 2 (the v2 update you just triggered).
Kubernetes keeps this history so you can roll back to any previous revision. This is what makes rollbacks possible with a single command.
Roll back to v1
Imagine you just deployed v2 and discovered a bug. In production, you need to revert immediately. Kubernetes makes this a one-command operation.
- Roll back to the previous version by running:
kubectl rollout undo deployment/k8s-nginx-deployment
What does rollout undo do?
This command tells Kubernetes to revert the deployment to its previous revision. It triggers the same rolling update process in reverse: pods running v2 are replaced one by one with pods running the v1 image.
The deployment.yaml file on disk is not changed. The rollback is entirely managed by Kubernetes internally.
- Watch the rollback complete by running:
kubectl rollout status deployment/k8s-nginx-deployment
Wait for the message confirming the deployment successfully rolled out.
- Confirm v1 is restored by curling the service:
curl $(minikube service k8s-nginx-service --url)
You should see Hello from Kubernetes! and Version: v1 in the response. Your original app is back.
- Verify both pods are healthy by running:
kubectl get pods
Both pods should show Running status with 1/1 containers ready.
Pods stuck in CrashLoopBackOff?
If a pod is not starting, check its logs with kubectl logs <pod-name> and describe it with kubectl describe pod <pod-name>. Look for image pull errors or container startup failures.
help me fix pods that are stuck after a Kubernetes rollback
Secret mission
Production-Harden Your Deployment
Your deployment works, but it wouldn't survive a production environment. Real clusters need resource guardrails to prevent runaway containers, self-healing probes to restart unhealthy pods, and sidecar containers for observability. Add all three to make your Pod spec CKA-exam ready.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker Desktop resources, so there are no ongoing costs.
Resources you used:
- Minikube cluster (contains your Deployment, Service, ConfigMap, Secret, and loaded container images).
- Project directory ~/k8s-nginx-project (Dockerfile, manifests, HTML files).
- Local Docker images k8s-nginx:v1 and k8s-nginx:v2 on your Docker daemon.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep the cluster around for future Kubernetes practice.
- Your Minikube cluster uses Docker Desktop resources but costs nothing to keep running.
- The kubectl alias in your ~/.bashrc remains available for future sessions.
- You can resume working on the project at any time by opening your Ubuntu WSL2 terminal and running kubectl commands.
Pause - I'll come back to this later
Shut down the cluster to free up memory and CPU, but keep everything intact so you can restart later.
- Stop the Minikube cluster by running:
minikube stop
- This preserves all your Kubernetes resources (Deployments, Services, ConfigMaps, Secrets). When you're ready to resume, restart the cluster by running:
minikube start
- After restarting, your Deployment, Service, and other resources will be restored automatically.
Delete - I don't want to use this again
Remove all project resources and start fresh. This reclaims disk space used by the cluster and container images.
- Delete the Minikube cluster entirely by running:
minikube delete
- This removes the cluster, all Kubernetes resources inside it, and all images loaded into it.
- Remove the local Docker images from your Docker daemon by running:
docker rmi k8s-nginx:v1 k8s-nginx:v2
- Remove the project directory by running:
rm -rf ~/k8s-nginx-project
- Remove the kubectl alias from your ~/.bashrc by opening the file in a text editor and deleting the line that says alias kubectl="minikube kubectl --".
- Confirm everything is gone by running:
ls ~/k8s-nginx-project
minikube status
docker images | grep k8s-nginx
- All three commands should return no results or errors, confirming full cleanup.
Nice Work!
Nice Work!
Nice work! You just deployed a custom nginx web server to a local Kubernetes cluster, configured it with production-grade patterns, updated it live, and rolled it back. That is a full lifecycle deployment on your own machine.
You've learned how to:
- Built a custom Docker image from a Dockerfile and deployed it to a local Kubernetes cluster running on Minikube with the Docker driver.
- Wrote and applied Kubernetes manifests (Deployment, Service, ConfigMap) and injected sensitive data using a Secret as environment variables.
- Performed a rolling update from v1 to v2 in real time and rolled back the deployment to the previous version with a single command.
- Secret Mission: Added resource limits, liveness/readiness probes, and a sidecar logging container for production-hardened deployments.
Ready to quiz yourself?