All articles

From zero to deployed: A guide to GKE deployments

A walkthrough on building, scaling, and updating applications in GKE using a production mindset.

#Kubernetes#GKE#DevOps#Production#Astro

It’s Monday morning. We just got the keys to the company’s new Google Kubernetes Engine (GKE) cluster. Our task is to deploy an Nginx web server, make sure it survives traffic spikes, and set up a way to safely test future software upgrades.

In a test environment, I might click around the Google Cloud Console to build this. But for this, I documented the hands-on practice along the way, learning the patterns that scale to production: reproducibility, version control, and resilient systems that recover automatically. We want manifests.

Let’s walk through how I built, scaled, and updated the application in GKE.

The Foundation

Before we build, we need to access the cluster. We interact with the cluster via the command line so every action can be tracked and automated later. To follow along, you’ll need the gcloud command-line tool. If you don’t have it installed, you can find instructions here.

We set our environment and fetch the cluster credentials:

export my_region=us-central1
export my_cluster=dev-cluster

source <(kubectl completion bash)

gcloud container clusters get-credentials $my_cluster --region $my_region

Instead of manually creating individual Pods (which Kubernetes won’t restart if they crash), we use a Deployment. A Deployment is a declarative way to tell Kubernetes you want 3 identical Nginx servers running at all times.

For a production system, a basic deployment isn’t enough. We need to define Resource Requests and Limits to ensure the application has the resources it needs and doesn’t consume more than its share.

Here is our production-ready nginx-deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.28.3
        ports:
        - containerPort: 80
        livenessProbe:
          httpGet:
            path: /
            port: 80
          initialDelaySeconds: 10
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 5
        resources:
          requests:
            cpu: 500m
            memory: 2Gi
            ephemeral-storage: 1Gi
          limits:
            ephemeral-storage: 1Gi

Notice the labels (app: nginx). Labels are how different components find and talk to each other in Kubernetes.

Apply the state to the cluster:

kubectl apply -f ./nginx-deployment.yaml

Check the deployments:

NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3/3     3            3           79s

Three out of three replicas are ready. The foundation is set.

Scaling On Demand

Traffic is spiking because marketing sent out an early promo email. Kubernetes lets you adapt quickly.

Let’s simulate scaling the deployment down to 1 replica to save costs during a quiet period:

kubectl scale --replicas=1 deployment nginx-deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   1/1     1            1           5m33s

When traffic hits, scale back up to 3:

kubectl scale --replicas=3 deployment nginx-deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3/3     3            3           5m53s

Kubernetes handles scheduling these new Pods onto available worker nodes automatically.

Health Checks

Deploying is one thing, but how does Kubernetes know your application is actually working? Without health checks, self-healing is incomplete. We need to add two types of probes:

We’ve already added these to our nginx-deployment.yaml to make our deployment more robust.

Friday Afternoon Upgrades

The dev team wants to update the Nginx image to version 1.30.3. Updating servers used to mean scheduled downtime. Kubernetes handles this with rolling updates. It replaces old Pods with new ones gradually.

Trigger the update by changing the image:

kubectl set image deployment.v1.apps/nginx-deployment nginx=nginx:1.30.3 
deployment.apps/nginx-deployment image updated

Maintain an audit trail by annotating the change:

kubectl annotate deployment nginx-deployment kubernetes.io/change-cause="version change to 1.30.3" --overwrite=true
deployment.apps/nginx-deployment annotated

Watch the rollout happen:

Kubernetes replaces the old Pods gradually with new ones, maintaining availability throughout. It spins up a new Pod with the updated image, waits for it to be ready, then removes an old Pod. This repeats until all three Pods are running the new version — zero downtime.

kubectl rollout status deployment.v1.apps/nginx-deployment
deployment "nginx-deployment" successfully rolled out

Let’s say version 1.30.3 has a memory leak and alarms are going off. You need to revert right away. Kubernetes tracks deployment history, so rolling back is a single command.

kubectl rollout undo deployments nginx-deployment
deployment.apps/nginx-deployment rolled back

Verify the history to see the system tracked the mistake and the fix:

kubectl rollout history deployment nginx-deployment
deployment.apps/nginx-deployment 
REVISION  CHANGE-CAUSE
2         version change to 1.30.3
3         <none>

Inspect the exact details of the current revision to confirm you are back on the stable 1.28.3 image. Now, the output will correctly reflect the resource requests we defined in our manifest.

kubectl rollout history deployment/nginx-deployment --revision=3
deployment.apps/nginx-deployment with revision #3
Pod Template:
  Labels:       app=nginx
        pod-template-hash=c5c8d64d4
  Containers:
   nginx:
    Image:      nginx:1.28.3
    Port:       80/TCP
    Host Port:  0/TCP
    Limits:
      ephemeral-storage:        1Gi
    Requests:
      cpu:      500m
      ephemeral-storage:        1Gi
      memory:   2Gi
    Environment:        <none>
    Mounts:     <none>
  Volumes:      <none>

Opening the Gates

The Pods are running, but they are isolated inside the cluster. You need to give users a stable entry point. A LoadBalancer service tells Google Cloud to provision a physical IP address that routes traffic to your Pods. For this introductory walkthrough, we’ll use LoadBalancer for its simplicity. In production GKE workloads, Ingress is typically preferred for cost-efficiency and routing control.

For this guide, we’ll stick with a LoadBalancer for simplicity. We’ll use the standard port 80.

Create service-nginx.yaml:

apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  type: LoadBalancer
  selector:
    app: nginx
  ports:
  - protocol: TCP
    port: 80
    targetPort: 80

Apply the service:

kubectl apply -f ./service-nginx.yaml
kubectl get service nginx

Initially, the IP is pending:

NAME    TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
nginx   LoadBalancer   34.118.229.12   <pending>     80:31373/TCP   10s

A few seconds later:

NAME    TYPE           CLUSTER-IP      EXTERNAL-IP     PORT(S)        AGE
nginx   LoadBalancer   34.118.229.12   34.56.174.120   80:31373/TCP   78s

The app is live to the world at http://34.56.174.120.

Testing Features With Canary Releases

A month goes by. The devs have an experimental feature. A full rolling update is risky because a failure could impact all users.

A canary release exposes the new version to a small subset of traffic. The simplest way to do this is to create a separate “canary” deployment.

Create nginx-canary.yaml with the new image version:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-canary
  labels:
    app: nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx
      track: canary
  template:
    metadata:
      labels:
        app: nginx
        track: canary
        version: 1.30.3
    spec:
      containers:
      - name: nginx
        image: nginx:1.30.3
        ports:
        - containerPort: 80
        # Production canaries should have the same resource/probe configuration

Apply the canary manifest:

kubectl apply -f ./nginx-canary.yaml

In production, this canary would also have resource requests/limits and health checks matching the main deployment.

The LoadBalancer distributes traffic randomly across all 4 Pods—roughly 25% to the canary. For true percentage-based canary releases (e.g., sending only 5% of traffic to test), you’d use Istio or Flagger.

You now have 4 Pods serving traffic across two deployments.

kubectl get deployments
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
nginx-canary       1/1     1            1           9s
nginx-deployment   3/3     3            3           11m

After monitoring logs and metrics, you confirm the canary is stable. This is the critical decision point—if metrics or logs show issues, you’d roll back the canary instead. Now, it’s time to promote it by updating the main deployment and removing the canary.

# Update the main deployment to the new version
kubectl set image deployment.v1.apps/nginx-deployment nginx=nginx:1.30.3

# Wait for the rollout to complete
kubectl rollout status deployment.v1.apps/nginx-deployment

# Remove the canary deployment
kubectl delete deployment nginx-canary

This ensures a safe, gradual transition to the new version for all users.

Final Thoughts

By working through this scenario, we’ve learned the core patterns that underpin production systems: self-healing Deployments, safe rollbacks, stable networking, and staged releases.