From `docker compose up` to `kubectl apply` — A Developer's First Kubernetes Deployment

You’ve finally mastered your docker-compose.yaml. You can confidently run docker compose up, and within seconds, your entire application stack spins up perfectly on your local machine. But now, your team is ready for production, and the mandate is clear: “We’re moving to Kubernetes.” If you’re feeling a bit overwhelmed, you’re not alone. The leap from Docker Compose to Kubernetes can feel like learning an entirely new language, filled with strange new vocabulary like Pods, Deployments, and Services.
However, the underlying concepts are surprisingly similar once you bridge the gap. In this guide, we’ll demystify that transition by mapping what you already know in Compose directly to Kubernetes.
Follow along! You can find all the code from this blog series in this GitHub repository: S1R15H/kubernetes-demo.
What You’ll Learn
- The critical shift from imperative commands to declarative state management.
- How to cleanly translate a local
docker-compose.yamlinto production-ready Kubernetes manifests. - The distinct roles of Deployments and Services in Kubernetes architecture.
- A practical workflow for building, pushing, and deploying your application.
- Crucial “gotchas” to watch out for during your first deployment.
The Declarative Mindset Shift
Before we look at a single line of YAML, we need to talk about mindset. The biggest hurdle when moving to Kubernetes isn’t the syntax; it’s the paradigm shift from an imperative approach to a declarative one.
Think of Docker Compose as a detailed recipe for a chef. You are handing the system a specific set of instructions: “Build this image, then mount this volume, then expose this port, and finally start the container.” It’s a sequence of events. If something breaks, the chef might stop, and you have to tell them how to fix it or start over.
Kubernetes, on the other hand, operates like a smart climate control system or a thermostat. You don’t tell the thermostat how to heat the room (turn on the furnace, open the vents, run the fan for 10 minutes). Instead, you simply declare the desired state: “I want the room to be 72 degrees.” The thermostat continuously monitors the current state of the room, and if the temperature drops to 68 degrees, it independently figures out what systems to engage to bring it back to 72.
In Kubernetes, you write YAML files to declare your “desired state” (e.g., “I want two instances of my Node.js API running and accessible on port 80”). Kubernetes has a control plane that constantly compares the actual state of your cluster to your desired state. If a server crashes and takes down one of your instances, Kubernetes notices the mismatch and automatically spins up a replacement to correct it. You declare the destination, and Kubernetes figures out the journey.
Code Walkthrough: The Rosetta Stone
Let’s translate this declarative concept into practice. We’ll start with a familiar baseline: our local Docker Compose file.
Here’s what our docker-compose.yaml looks like:
services:
api:
build:
context: .
dockerfile: Dockerfile
container_name: node-api
ports:
- "6789:6789"
environment:
NODE_ENV: production
volumes:
- .:/app
- /app/node_modules
command: npm run dev
This file is concise and perfect for local development. It builds our image from source, maps port 6789, sets the environment variable NODE_ENV, and mounts local volumes for hot-reloading with npm run dev.
Now, let’s see how this exact same application is deployed to a Kubernetes cluster. In Kubernetes, this single Compose file is typically split into two distinct resources: a Deployment (to manage the application containers) and a Service (to manage network access).
Step 1: The Deployment
In Kubernetes, a Deployment is responsible for keeping a set of identical Pods running. Think of a Pod as an apartment, and the containers as the rooms inside it. While a Pod can hold multiple containers, most of the time you’ll have one container per Pod.
Here is our Kubernetes equivalent, k8s/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: kubernetes-demo-api
labels:
app: kubernetes-demo-api
spec:
replicas: 2
selector:
matchLabels:
app: kubernetes-demo-api
template:
metadata:
labels:
app: kubernetes-demo-api
spec:
containers:
- name: kubernetes-demo-api
image: USERNAME/kubernetes-demo-api:latest
ports:
- containerPort: 6789
env:
- name: NODE_ENV
value: "production"
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "512Mi"
readinessProbe:
httpGet:
path: /ready
port: 6789
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 6789
initialDelaySeconds: 10
periodSeconds: 20
This file is substantially longer than our Compose file. Let’s break down exactly why we need these configurations:
spec.replicas: 2: Why do we specify replicas here? In Docker Compose, you typically run one instance locally. In production, you want high availability. By declaringreplicas: 2, we tell Kubernetes to run two identical Pods. If one Pod crashes, or the underlying node dies, the other Pod continues serving traffic while Kubernetes spins up a replacement.- Labels and Selectors: Notice that the label
app: kubernetes-demo-apiappears three times!- The top
metadata.labelsorganizes the Deployment itself. - The
spec.template.metadata.labelsapplies to the actual Pods that are created. - Crucially, the
spec.selector.matchLabelstells the Deployment which Pods it is responsible for managing. If a Pod has this label, the Deployment tracks its health.
- The top
imageinstead ofbuild: Why is there nobuildcontext like in Compose? Kubernetes does not build code. It is an orchestrator of pre-built images. We provideimage: USERNAME/kubernetes-demo-api:latest, which tells Kubernetes to pull the ready-to-run image from a remote registry (like Docker Hub or AWS ECR).envblock: We passNODE_ENVas a literal string. But we also showcase Kubernetes’ power by dynamically injecting thePOD_NAMEusing the Downward API (valueFrom.fieldRef). This allows the container to know its own unique identity.resources: In production, noisy neighbors can crash your nodes if a container consumes unbounded CPU or memory. We definerequests(guaranteed minimums) andlimits(hard caps) to ensure stable performance.- Probes: The
readinessProbeandlivenessProbeare essential. They answer the questions: “Is this container ready to receive traffic?” and “Is this container hopelessly frozen and in need of a restart?” We ping/readyand/healthon port6789.
Step 2: The Service
In Docker Compose, mapping a port with ports: - "6789:6789" automatically exposes your container to the host network.
In Kubernetes, Pods are ephemeral. They are constantly being created, destroyed, and scaled up or down. Every time a new Pod is created, it gets a completely random internal IP address. If we relied on Pod IP addresses, our users would constantly be disconnected.
To solve this, we use a Service. A Service provides a stable, unchanging IP address and DNS name that load-balances traffic across our ephemeral Pods.
Here is k8s/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: kubernetes-demo-api-service
labels:
app: kubernetes-demo-api
spec:
selector:
app: kubernetes-demo-api
ports:
- protocol: TCP
port: 6789 # Service port
targetPort: 6789 # Container port
type: NodePort # change to LoadBalancer if running in cloud
Let’s dissect the why behind this configuration:
spec.selector: This is the magic linking mechanism in Kubernetes. How does this Service know which of the potentially thousands of Pods in the cluster it should route traffic to? It looks for Pods tagged with the exact labelapp: kubernetes-demo-api(the ones generated by our Deployment’s Pod template).portvstargetPort: Why is the port split in two? This decouples the Service’s external interface from the container’s internal implementation.port: 6789is the port the Service listens on within the cluster.targetPort: 6789is the port the container is actually running on.- This means your Node.js app could be hardcoded to run on
6789internally (targetPort), but you could expose the Service on standard port80(port), without touching your application code.
type: NodePort: This explicitly opens a port on the cluster’s worker nodes to allow external traffic in. In a real cloud environment like AWS or GCP, you would change this totype: LoadBalancerto automatically provision an external cloud load balancer.
Step 3: The Deployment Workflow
With Docker Compose, your workflow is often just docker compose up --build. Because Kubernetes separates building from running, our deployment process requires a few distinct steps.
Here is a look at our deploy.sh script, which automates this workflow:
set -e
NAME="kubernetes-demo-api"
USERNAME="username"
IMAGE="$USERNAME/$NAME:latest"
echo "Building Docker image..."
docker build -t $IMAGE .
echo "Pushing Docker image to Docker Hub..."
docker push $IMAGE
echo "Applying Kubernetes manifests..."
kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
echo "Getting pods..."
kubectl get pods
echo "Getting services..."
kubectl get services
echo "Fetching the main service"
kubectl get services $NAME-service
Let’s break down why this script is structured this way:
- Build: First, we must package our application by running
docker build. We tag it with our registry username. - Push: This is a critical step that doesn’t exist in local Compose workflows. We must run
docker pushto upload our image to a remote registry. If the image only lives on your local laptop, the Kubernetes cluster’s worker nodes will not be able to download and run it. - Apply: We execute
kubectl apply -f k8s/deployment.yamlandkubectl apply -f k8s/service.yaml. We are handing our declarative “desired state” YAML files to the Kubernetes control plane. - Verify: Finally, we run
kubectl get podsandkubectl get servicesto verify that Kubernetes is actively spinning up our resources.
Common Mistakes / Gotchas
As you transition to Kubernetes, you will inevitably hit a few roadblocks. Here are the most common pitfalls to watch out for:
The Dreaded “ImagePullBackOff” Error: One of the most frequent mistakes developers make is skipping the
docker pushstep. If you apply a Deployment, but Kubernetes cannot find the specified image in a remote registry, your Pods will fail to start. When you runkubectl get pods, you will see the statusImagePullBackOfforErrImagePull. Always ensure your image name is spelled correctly and that you have pushed it to a registry the cluster can access.
Mismatched Selectors and Black Hole Services: If your Service’s
selectorlabels (e.g.,app: kubernetes-demo-api) do not exactly match your Deployment’stemplate.metadata.labels, Kubernetes won’t throw an explicit error. However, your Service won’t route traffic to any Pods. You’ll be left scratching your head as to why your app is unreachable. Always double-check your label spelling and capitalization.
Misunderstanding TargetPort vs Port: It’s easy to accidentally swap
portandtargetPortin your Service configuration. If your Service is listening on 80, but sending traffic to container port 80 (when your Node.js app is actually listening on 6789), the connection will be refused. EnsuretargetPortprecisely matches thecontainerPortdefined in your Deployment.
Summary / Key Takeaways
Transitioning from Compose to Kubernetes is a powerful step up in reliability and scale. Let’s recap the core concepts:
- Embrace the Declarative Mindset: Stop thinking in step-by-step scripts. Start declaring your desired state using YAML, and trust Kubernetes to make it a reality.
- Deployments Manage Pods: Deployments replace your Compose
servicesblock. They define the container specs, handle environment variables, ensure high availability via replicas, and manage self-healing. - Services Provide Stable Networking: Because Pods are ephemeral and their IPs change, Services act as a reliable front-door, using label selectors to accurately route traffic to healthy Pods.
- Build, Push, Apply: The Kubernetes workflow fundamentally separates building your artifact from running it. You must always push your images to a shared registry before applying your manifests.
What’s Next?
Now that you have your first Kubernetes deployment up and running, you might notice that your Docker images are quite large, causing slower build and pull times. In Post 2 of this series, we’ll dive into optimizing Node.js Dockerfiles using multi-stage builds to keep your deployments lean, secure, and lightning-fast. Stay tuned!