Dockerize and Deploy to Kubernetes

Containerize a Python app and deploy it to Kubernetes using Terraform IaC.

Introduction

30 Second Summary

Every time you use a banking app, stream a show, or order food online, the software behind it is running inside containers managed by automated infrastructure. The tools that make this possible are Docker, Kubernetes, and Terraform.

In this project, you will build a Python web application, package it into a Docker container, deploy it to a Kubernetes cluster, and manage the entire deployment using Terraform as Infrastructure as Code.

What You'll Build

A containerized Python web application deployed to your local Kubernetes cluster, fully managed by Terraform as Infrastructure as Code.

By the end of this project, you'll have:

  • A custom Docker image built from a Dockerfile that packages a Python Flask web app into a container.
  • A Terraform configuration that provisions a Kubernetes namespace, deployment with 2 replicas, and a NodePort service.
  • Hands-on experience with the full Terraform lifecycle (init, plan, apply, destroy) that transfers directly to cloud infrastructure provisioning.
  • Secret Mission: Scale your deployment to 4 replicas and add a second microservice (Redis) managed by Terraform, simulating a real multi-service architecture.

Are there any prerequisites?

You should have completed a DevOps beginner course and have basic command-line familiarity. You also need Docker Desktop installed with Kubernetes enabled.

Install and Verify Terraform

Before you can deploy anything with Terraform, you need it installed on your machine. Terraform is a single binary file that you download, place in a folder, and tell Windows where to find it.

In this step, you will install Terraform and confirm that your local Kubernetes cluster is ready to receive deployments. These two tools form the foundation of everything you will build in this project.

In this step, get ready to:
  • Install Terraform 1.15.4 and add it to your system PATH.
  • Verify the installation with terraform version.
  • Confirm your Docker Desktop Kubernetes cluster is accessible.
Download and install Terraform

Terraform is distributed as a single executable file inside a .zip archive. You download the archive, extract the binary, and add its location to your system PATH so Windows can find it from any terminal.

  • Check if Terraform is already installed by running this command in your terminal:
terraform version

✔️ I see version 1.15.4 or higher

Terraform is ready to go. Skip ahead to the next substep where you verify your Kubernetes cluster.

ⓧ I see an older version

You have an outdated version of Terraform installed. Replace it with the latest version by following the same steps as a fresh install below.

  • Open your browser and go to the Terraform install page:
  • https://developer.hashicorp.com/terraform/install
  • Under **Windows**, click the **AMD64** download link to download the .zip file.
  • Open the downloaded .zip file and extract terraform.exe to replace the existing binary in the same folder (e.g. C:\terraform).
  • Open a new terminal window and run terraform version again to confirm you now see 1.15.4.

Still seeing the old version?

Close all open terminal windows and open a fresh one. Windows caches the PATH at the time a terminal opens, so older sessions will not see the new binary.

help me troubleshoot my Terraform version not updating after I replaced the binary

ⓧ Command not found

Terraform is not installed yet. Follow these steps to install it.

  • Open your browser and go to the Terraform install page:
  • https://developer.hashicorp.com/terraform/install
  • Under Windows, click the AMD64 download link to download the .zip file.
  • Open File Explorer and create a new folder at C:\terraform.
  • Open the downloaded .zip file and extract terraform.exe into the C:\terraform folder.

Now you need to tell Windows where to find the binary by adding it to your system PATH.

  • Press the Windows key and type Environment Variables.
  • Click Edit the system environment variables.
  • Click the Environment Variables button at the bottom of the dialog.
  • Under System variables, find the row named Path and click Edit.
  • Click New and type C:\terraform.
  • Click OK on all open dialogs to save.
  • Close any open terminal windows and open a new one.
  • Verify the install by running:
terraform version

You should see output that includes 1.15.4 in the version number.

Still seeing command not found?

Make sure you opened a brand new terminal after editing the PATH. Existing terminals do not pick up PATH changes.

Double check that terraform.exe is directly inside C:\terraform (not inside a nested subfolder).

help me troubleshoot Terraform not being recognized after adding it to my PATH on Windows

What is Terraform?

Terraform is an Infrastructure as Code tool made by HashiCorp. Instead of clicking through dashboards or running one-off commands to create infrastructure, you write a configuration file that describes what you want.

Terraform reads that file and figures out how to create, update, or delete resources to match your description. You will use this workflow throughout the rest of the project.

Verify your Kubernetes cluster

Your Docker Desktop installation includes a local Kubernetes cluster. You need to confirm it is running and accessible before you can deploy to it.

  • Confirm your Kubernetes cluster is running by running this command:
kubectl cluster-info

✔️ I see Kubernetes control plane is running

Your cluster is healthy and ready for deployments. You should see output showing the Kubernetes control plane running at a local address, along with CoreDNS.

ⓧ I see an error or connection refused

This means Kubernetes is not enabled or not running inside Docker Desktop.

  • Open Docker Desktop.
  • Click the gear icon to open Settings.
  • Select Kubernetes in the left sidebar.
  • Check the box labeled Enable Kubernetes.
  • Click Apply & Restart.
  • Wait for the Kubernetes status indicator (bottom-left corner of Docker Desktop) to turn green.
  • Run kubectl cluster-info again in a new terminal.

Still not connecting?

Try restarting Docker Desktop entirely. Kubernetes can take 1-2 minutes to start after enabling it for the first time.

help me troubleshoot kubectl not connecting to my Docker Desktop Kubernetes cluster

Your tools are locked and loaded. Next up, you will build a Docker image that packages a Python web application into a container ready for Kubernetes.

Build Your Docker Image

Your DevOps toolkit is installed and verified. Now you need an application to containerize and deploy.

In this step, you'll create a Python Flask web app and package it into a Docker image. The image bundles your code and all its dependencies into one portable unit that runs identically on any machine.

In this step, get ready to:
  • Create a Python Flask web application that returns system info.
  • Write a Dockerfile to containerize the application.
  • Build and test the Docker image locally.
Create the Flask application

Every Docker image starts with application code. You'll build a minimal Flask web server that returns its hostname and a timestamp. These details will prove later that multiple containers are serving traffic independently.

  • In VS Code, open a terminal by pressing Ctrl+` (backtick) or clicking Terminal > New Terminal in the menu bar.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and the app subdirectory by running these commands:
mkdir -p devops-lab/app
cd devops-lab
  • Confirm the folders exist by running:
ls

You should see app in the output.

  • Open the devops-lab folder in VS Code by clicking File > Open Folder.
  • Navigate to your Desktop and select the devops-lab folder, then click Open.

Why open the folder in VS Code?

Opening the project folder gives VS Code's Explorer sidebar visibility into your entire project structure. It also sets the integrated terminal's working directory to your project root.

  • In the Explorer sidebar on the left, right-click the app folder and select New File.
  • Name the file app.py and press Enter.
  • Paste the following code into the file:
from flask import Flask, jsonify
import socket
import datetime

app = Flask(__name__)

# Root route returns system info as JSON
@app.route("/")
def home():
    return jsonify(
        message="Hello from Kubernetes!",
        hostname=socket.gethostname(),
        timestamp=datetime.datetime.now().isoformat()
    )

# Health check endpoint for Kubernetes liveness probes
@app.route("/health")
def health():
    return jsonify(status="healthy")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

This Flask app defines two routes:

  • The / route returns a JSON payload with a greeting, the container's hostname, and the current timestamp. When you run multiple replicas later, each container will report a different hostname.
  • The /health route returns a simple status. Kubernetes can use this to check whether the container is still alive.
  • The host="0.0.0.0" setting tells Flask to accept connections from outside the container, not just from localhost.
  • Save app.py by pressing Ctrl+S (Windows) or Cmd+S (macOS).

You won't see a visible change yet. The app needs Flask installed to run, which the Docker image will handle.

  • Right-click the app folder again and select New File.
  • Name the file requirements.txt and paste the following:
flask==3.1.1

What is requirements.txt?

This file lists every Python package your app depends on with an exact version pin. Docker will install these packages inside the image so the container has everything it needs to run without relying on your local machine.

  • Save requirements.txt.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

from flask import Flask, jsonify
import socket
import datetime

app = Flask(__name__)

@app.route("/")
def home():
    return jsonify(
        message="Hello from Kubernetes!",
        hostname=socket.gethostname(),
        timestamp=datetime.datetime.now().isoformat()
    )

@app.route("/health")
def health():
    return jsonify(status="healthy")

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
flask==3.1.1
Write the Dockerfile

A Dockerfile is a recipe that tells Docker how to build your image layer by layer. Each instruction adds a new layer: the base operating system, your dependencies, your code, and the command that starts your app.

  • Right-click the app folder and select New File.
  • Name the file Dockerfile (no extension) and paste the following:
# Start from a minimal Python image
FROM python:3.12-slim

# Set the working directory inside the container
WORKDIR /app

# Copy and install dependencies first (layer caching optimization)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy the application code
COPY app.py .

# Document which port the container listens on
EXPOSE 5000

# Start the Flask app when the container launches
CMD ["python", "app.py"]

What does this Dockerfile do?

  • FROM python:3.12-slim starts from an official Python image that's small but production-ready.
  • WORKDIR /app creates and switches into a /app directory inside the container.
  • COPY requirements.txt . and RUN pip install install dependencies before copying your code. This ordering means Docker can cache the dependency layer and skip reinstalling packages when only your code changes.
  • CMD ["python", "app.py"] is the default command that runs when a container starts from this image.
  • Save Dockerfile.

You won't see a change yet. The next substep uses this Dockerfile to build the image.

✔️ Awesome, I've got everything!

Great. Confirm you've saved the Dockerfile before moving on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]
Build and run your Docker image

With all three files in place, you can now build the Docker image and run it as a container to verify everything works.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick).
  • Build the Docker image by running this command:
docker build -t devops-flask-app:latest ./app

What does this command do?

  • docker build reads your Dockerfile and executes each instruction to create an image.
  • -t devops-flask-app:latest tags the image with a name and version so you can reference it later.
  • ./app tells Docker to use the app/ folder as the build context (where it looks for the Dockerfile and files to COPY).

The build takes 30-60 seconds the first time while Docker downloads the Python base image. You should see output ending with a line that includes the image name devops-flask-app:latest.

Build failing?

  • Make sure Docker Desktop is running. The Docker daemon must be active for builds to work.
  • Confirm you are in the devops-lab root folder (not inside app/). Run pwd to check.
  • Check that your Dockerfile is named exactly Dockerfile with a capital D and no file extension.

Why is my Docker build failing?

  • Run your new image as a container to test it:
docker run -p 5000:5000 devops-flask-app:latest

What does this command do?

The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container. This lets you access the Flask app from your browser at http://localhost:5000.

Your terminal is now running the Flask server. You should see output like Running on http://0.0.0.0:5000.

  • Open your browser and navigate to http://localhost:5000.

You should see a JSON response containing a message, hostname, and timestamp field. The hostname is a random string. This is the container's internal ID.

  • Return to your terminal and press Ctrl+C to stop the container.

Not seeing the JSON response?

  • Make sure the terminal is still showing the Flask output. If you see an error instead, the container may have crashed on startup.
  • Check that nothing else is using port 5000. On macOS, AirPlay Receiver sometimes occupies this port. Disable it in System Settings > General > AirDrop & Handoff.
  • Confirm you typed http://localhost:5000 (not https).

Help me debug my Docker container not responding on port 5000.

Your Flask app is packaged into a Docker image and verified running as a container. Next up, you'll deploy this image to Kubernetes and see how container orchestration manages multiple replicas of your app.

Deploy to Kubernetes with kubectl

Your Docker image is built and tested locally. Now it is time to run it on Kubernetes.

In this step, you will write a YAML manifest that tells Kubernetes exactly what to run and how to expose it. You will deploy manually with kubectl first so you can feel the friction of managing infrastructure by hand.

In this step, get ready to:
  • Write a Kubernetes manifest with a Namespace, Deployment, and Service.
  • Apply the manifest to your cluster and verify the pods are running.
  • Understand why manual YAML management becomes painful at scale.
Create the Kubernetes manifest

A Kubernetes manifest is a YAML file that describes the desired state of your cluster. You will define three resources in a single file.

  • Create a k8s folder inside your project directory by running this command:
mkdir k8s
  • Create a new file called deployment.yaml inside the k8s folder. In VS Code, right-click the k8s folder in the Explorer sidebar and select New File.
  • Name the file deployment.yaml.
  • Add the Namespace and Deployment definitions by pasting the following code into k8s/deployment.yaml:
apiVersion: v1
kind: Namespace
metadata:
  name: devops-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-app
  namespace: devops-lab
spec:
  replicas: 2
  selector:
    matchLabels:
      app: flask-app
  template:
    metadata:
      labels:
        app: flask-app
    spec:
      containers:
        - name: flask-app
          image: devops-flask-app:latest
          imagePullPolicy: Never
          ports:
            - containerPort: 5000

What does this code do?

The Namespace creates an isolated area called devops-lab inside your cluster. This keeps your resources separate from the default system pods.

The Deployment tells Kubernetes to run 2 replicas of your Flask container. imagePullPolicy: Never tells Kubernetes to use the image you already built locally instead of trying to pull from a remote registry.

You won't see a change yet. The next chunk adds the Service, then you will apply the whole manifest together.

  • Add the Service definition below the Deployment. Paste this code at the bottom of k8s/deployment.yaml:
---
apiVersion: v1
kind: Service
metadata:
  name: flask-service
  namespace: devops-lab
spec:
  type: NodePort
  selector:
    app: flask-app
  ports:
    - port: 5000
      targetPort: 5000
      nodePort: 30080

What does this code do?

A NodePort Service exposes your pods to traffic outside the cluster. It maps port 30080 on your machine to port 5000 inside each pod.

The selector field matches the app: flask-app label on your Deployment's pods. This is how the Service knows which pods to route traffic to.

  • Save k8s/deployment.yaml.
  • Confirm the file exists by running:
ls k8s

You should see deployment.yaml in the output.

Don't see the file?

Make sure you created the k8s folder with mkdir k8s before creating the file inside it. If you accidentally created deployment.yaml in the project root, move it into the k8s folder.

Help me find where my deployment.yaml ended up

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

apiVersion: v1
kind: Namespace
metadata:
  name: devops-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: flask-app
  namespace: devops-lab
spec:
  replicas: 2
  selector:
    matchLabels:
      app: flask-app
  template:
    metadata:
      labels:
        app: flask-app
    spec:
      containers:
        - name: flask-app
          image: devops-flask-app:latest
          imagePullPolicy: Never
          ports:
            - containerPort: 5000
---
apiVersion: v1
kind: Service
metadata:
  name: flask-service
  namespace: devops-lab
spec:
  type: NodePort
  selector:
    app: flask-app
  ports:
    - port: 5000
      targetPort: 5000
      nodePort: 30080
Apply the manifest and verify your pods

Your manifest describes the desired state. Now you will hand it to Kubernetes and let the cluster make it real.

  • Deploy everything to your cluster by running this command:
kubectl apply -f k8s/deployment.yaml

What does this command do?

The kubectl apply command reads your YAML file and sends each resource definition to the Kubernetes API server. The cluster then creates the Namespace, spins up 2 pod replicas, and configures the Service.

You should see output confirming all three resources were created:

namespace/devops-lab created
deployment.apps/flask-app created
service/flask-service created
  • Verify your pods are running by running:
kubectl get pods -n devops-lab

You should see 2 pods with a STATUS of Running. It may take a few seconds for them to start.

Pods stuck in ImagePullBackOff or ErrImagePull?

This means Kubernetes is trying to pull your image from a remote registry instead of using the local one. Check that your k8s/deployment.yaml includes imagePullPolicy: Never under the container spec.

Also verify that your image name matches exactly. Run docker images and confirm devops-flask-app with tag latest is listed.

Help me debug my ErrImagePull error in Kubernetes

Understand why manual YAML becomes painful

You just deployed a working application with a single file. That feels manageable. But consider what happens as your project grows.

Why is manual kubectl painful at scale?

  • No state tracking. Kubernetes does not remember which YAML files you applied. If you delete a resource from the file and re-apply, the old resource stays running.
  • No drift detection. If someone manually edits a running pod, you have no way to know your file is out of sync with reality.
  • No dependency graph. You must apply files in the right order yourself. A Deployment that references a missing Namespace will fail.
  • No clean teardown. Deleting everything requires remembering every resource you created. Miss one and it lingers forever.

These are exactly the problems that Terraform solves. Terraform tracks state, detects drift, understands dependencies, and can destroy everything in one command.

Your cluster is running, but managing it by hand is fragile. In the next step, you will rewrite this entire deployment as Terraform Infrastructure as Code and experience the difference firsthand.

Manage Kubernetes with Terraform

In the previous step, you deployed your Flask app to Kubernetes by writing YAML manifests and running kubectl apply. It worked, but you had to manage every detail manually.

Now you'll rebuild that entire deployment using Terraform. Instead of running commands one by one, you'll declare your desired state in a single configuration file. Terraform reads that file, compares it to reality, and creates exactly what's missing.

In this step, get ready to:
  • Write a Terraform configuration with the Kubernetes provider.
  • Define a namespace, deployment, and service as Terraform resources.
  • Run the full Terraform workflow (init, plan, apply) to provision everything declaratively.
Write the Terraform provider configuration

Terraform needs two things to manage your Kubernetes cluster: a provider plugin that knows how to talk to the Kubernetes API, and a connection path to your cluster.

  • Create a terraform directory inside your project folder by running these commands:
mkdir terraform
cd terraform
  • Create a new file called main.tf inside the terraform directory and add the following provider configuration:
terraform {
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.35"
    }
  }
  required_version = ">= 1.0"
}

provider "kubernetes" {
  config_path = "~/.kube/config"
}

What does this code do?

The terraform block declares that this project needs the hashicorp/kubernetes provider at version ~> 2.35 (any 2.x release from 2.35 onward).

The provider block tells Terraform how to connect to your cluster. config_path = "~/.kube/config" points to the same kubeconfig file that kubectl uses. Docker Desktop writes this file automatically when you enable Kubernetes.

Why Terraform instead of more YAML?

Terraform tracks state. It remembers every resource it created, detects drift from your desired configuration, and applies only the minimum changes needed. With raw kubectl YAML, you have no built-in way to know what changed or roll back cleanly.

The same terraform init/plan/apply workflow works for AWS, Azure, GCP, and hundreds of other providers. Learning it here transfers directly to cloud infrastructure.

  • Save main.tf.

You won't see a change yet. Next, you'll add the Kubernetes resources that Terraform will manage.

Define the Kubernetes resources

You'll define three resources in the same main.tf file: a namespace to isolate your workloads, a deployment to run your Flask pods, and a service to expose them.

  • In main.tf, add the namespace resource below the provider block:
resource "kubernetes_namespace" "devops_lab" {
  metadata {
    name = "devops-lab-tf"
  }
}

What does this code do?

This creates a Kubernetes namespace called devops-lab-tf. The -tf suffix distinguishes it from the devops-lab namespace you created manually with kubectl earlier. Both can coexist in the same cluster.

You won't see a change yet. The deployment resource comes next and references this namespace.

  • Below the namespace resource, add the deployment resource:
resource "kubernetes_deployment" "flask_app" {
  metadata {
    name      = "flask-app"
    namespace = kubernetes_namespace.devops_lab.metadata[0].name
    labels = {
      app = "flask-app"
    }
  }

  spec {
    replicas = 2

    selector {
      match_labels = {
        app = "flask-app"
      }
    }

    template {
      metadata {
        labels = {
          app = "flask-app"
        }
      }

      spec {
        container {
          image             = "devops-flask-app:latest"
          image_pull_policy = "Never"
          name              = "flask-app"

          port {
            container_port = 5000
          }
        }
      }
    }
  }
}

What does this code do?

  • kubernetes_namespace.devops_lab.metadata[0].name is a Terraform reference. Instead of hardcoding the namespace name, Terraform reads it from the namespace resource you just defined. This creates an explicit dependency between resources.
  • replicas = 2 runs two identical pods for redundancy.
  • image_pull_policy = "Never" tells Kubernetes to use your locally-built Docker image instead of trying to pull from a remote registry.

You won't see a change yet. One more resource to add.

  • Below the deployment resource, add the service resource:
resource "kubernetes_service" "flask_service" {
  metadata {
    name      = "flask-service"
    namespace = kubernetes_namespace.devops_lab.metadata[0].name
  }

  spec {
    selector = {
      app = "flask-app"
    }

    type = "NodePort"

    port {
      port        = 5000
      target_port = 5000
      node_port   = 30090
    }
  }
}

What does this code do?

  • type = "NodePort" exposes the service on a static port on every node in your cluster.
  • node_port = 30090 is a different port from your earlier kubectl service (which used 30080). This lets both services coexist while you compare the two approaches.
  • selector matches pods with the label app = "flask-app", routing traffic to your deployment's pods.
  • Save main.tf.

Seeing red squiggles in VS Code?

VS Code may show syntax warnings for .tf files if you don't have a Terraform extension installed. The file will still work perfectly with the terraform CLI. You can ignore the warnings or install the HashiCorp Terraform extension for syntax highlighting.

Help me troubleshoot warnings in my main.tf file

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf with all four blocks: the terraform block, the provider block, and the three resource blocks (namespace, deployment, service).

ⓧ I'd like to double check the full code

Here's the complete terraform/main.tf file. Compare your code against it:

terraform {
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.35"
    }
  }
  required_version = ">= 1.0"
}

provider "kubernetes" {
  config_path = "~/.kube/config"
}

resource "kubernetes_namespace" "devops_lab" {
  metadata {
    name = "devops-lab-tf"
  }
}

resource "kubernetes_deployment" "flask_app" {
  metadata {
    name      = "flask-app"
    namespace = kubernetes_namespace.devops_lab.metadata[0].name
    labels = {
      app = "flask-app"
    }
  }

  spec {
    replicas = 2

    selector {
      match_labels = {
        app = "flask-app"
      }
    }

    template {
      metadata {
        labels = {
          app = "flask-app"
        }
      }

      spec {
        container {
          image             = "devops-flask-app:latest"
          image_pull_policy = "Never"
          name              = "flask-app"

          port {
            container_port = 5000
          }
        }
      }
    }
  }
}

resource "kubernetes_service" "flask_service" {
  metadata {
    name      = "flask-service"
    namespace = kubernetes_namespace.devops_lab.metadata[0].name
  }

  spec {
    selector = {
      app = "flask-app"
    }

    type = "NodePort"

    port {
      port        = 5000
      target_port = 5000
      node_port   = 30090
    }
  }
}
Run the Terraform workflow

With your configuration written, you'll now run the three core Terraform commands: init to download the provider, plan to preview changes, and apply to create the resources.

  • Make sure your terminal is inside the terraform directory, then initialize the project by running this command:
terraform init
  • You should see output ending with Terraform has been successfully initialized! confirming the hashicorp/kubernetes provider was downloaded.

What does terraform init do?

This command downloads the provider plugin (hashicorp/kubernetes) into a hidden .terraform directory. It also creates a .terraform.lock.hcl file that locks the exact provider version for reproducible builds.

You must run terraform init once in every new Terraform directory before you can use any other Terraform commands.

Seeing a provider error?

If you see an error about provider installation failing, check that your internet connection is active. Terraform downloads the provider plugin from the HashiCorp registry during init.

If you see a version constraint error, make sure the version = "~> 2.35" line in your main.tf matches exactly.

Help me troubleshoot terraform init

  • Preview what Terraform will create by running:
terraform plan
  • You should see output showing three resources with a + symbol (meaning they'll be created), ending with Plan: 3 to add, 0 to change, 0 to destroy. at the bottom.

What does terraform plan do?

Plan compares your configuration file against the current state (which is empty right now, since you haven't applied anything yet). It shows you exactly what Terraform will create, modify, or destroy before it touches anything. This is your safety net.

In production, teams review the plan output before approving any infrastructure changes. Nothing happens to your cluster until you explicitly run apply.

  • Apply the configuration to create all three resources by running:
terraform apply

Terraform will ask for confirmation

After showing the plan, Terraform prompts you with Do you want to perform these actions? and waits for input.

Type yes and press Enter to confirm. Any other response cancels the operation.

  • Type yes when prompted and press Enter.
  • You should see output ending with Apply complete! Resources: 3 added, 0 changed, 0 destroyed. confirming all three resources were created.
  • Verify your Terraform-managed pods are running by running:
kubectl get pods -n devops-lab-tf
  • You should see two pods with names starting with flask-app- in a Running state.

Pods not showing as Running?

If you see ErrImagePull or ImagePullBackOff, Kubernetes is trying to pull the image from a remote registry. Make sure image_pull_policy = "Never" is set in your deployment resource and that you built your Docker image with the exact tag devops-flask-app:latest.

If pods show Pending, Docker Desktop Kubernetes may need more resources. Check that Docker Desktop is running and has sufficient memory allocated in its settings.

Help me troubleshoot my Terraform-managed pods

Your Kubernetes infrastructure is now fully managed by Terraform. Next up, you'll access your running app in the browser, modify your configuration to see Terraform's drift detection in action, and learn how to cleanly tear everything down.

Verify, Modify, and Destroy

Your Terraform-managed Kubernetes deployment is live. In the previous step, you provisioned a namespace, deployment, and service with a single terraform apply command.

Now it's time to experience the full Terraform lifecycle. You'll access your running app in the browser, change a single number in your config to scale your deployment, and then cleanly destroy everything with one command.

In this step, get ready to:
  • Access your Flask app in the browser via the NodePort service.
  • Modify the replica count and watch Terraform detect and apply only the diff.
  • Destroy all Terraform-managed resources with a single command.
Access your running app in the browser

Your Terraform-managed NodePort service exposes your Flask app at port 30090 on localhost. Docker Desktop Kubernetes makes NodePort services accessible directly at localhost.

  • Open your web browser.
  • Navigate to http://localhost:30090.

You should see a JSON response with three fields: message, hostname, and timestamp.

What does the JSON response tell you?

The hostname field shows the pod name that handled your request. Since you have 2 replicas, refreshing the page may show a different hostname each time. This is Kubernetes load-balancing traffic across your pods.

The timestamp confirms the response is generated live, not cached.

Not seeing a response at localhost:30090?

  • Make sure Docker Desktop is running and its Kubernetes cluster is enabled (check the Docker Desktop tray icon for a green dot).
  • Confirm your pods are running by checking kubectl get pods -n devops-lab-tf in your terminal. Both pods should show Running status.
  • If pods show ErrImagePull, the Docker image was not found locally. Rebuild with docker build -t devops-flask-app:latest ./app and re-run terraform apply.

Why can't I access my Flask app at localhost:30090?

  • Refresh the page a few times to see the hostname change as Kubernetes routes to different pods.
Modify the replica count and apply the change

One of Terraform's most powerful features is drift detection. When you change your configuration, Terraform compares the desired state (your .tf files) against the actual state (tracked in terraform.tfstate) and applies only the difference.

Let's scale your deployment from 2 replicas to 3 by changing a single number.

  • In VS Code, open terraform/main.tf.
  • Inside the kubernetes_deployment resource block, find this line in the spec block:
replicas = 2
  • Change 2 to 3, so the line now reads:
replicas = 3
  • Save main.tf.

Now let Terraform detect and apply this change.

  • Run the plan command to preview what Terraform will change:
terraform plan

Terraform's output shows it will modify only the deployment's replica count. It does not touch the namespace or service. This is the power of declarative infrastructure.

  • Apply the change by running:
terraform apply
  • Terraform prompts you to confirm. Type yes and press Enter.
  • Verify the new replica count by running:
kubectl get pods -n devops-lab-tf

You should now see 3 pods listed, all with Running status. Terraform scaled your deployment by changing a single line of config.

Why is this better than editing YAML manually?

When you used kubectl apply in Step 3, you had to know which YAML file to edit and manually track what changed. Terraform handles this automatically. It compares your desired state to the actual state and applies only the minimum necessary changes.

This pattern scales. In production, a single Terraform config can manage hundreds of resources across multiple providers. Changing one number triggers a targeted update, not a full redeployment.

Still seeing only 2 pods?

  • Make sure you saved main.tf before running terraform apply. Unsaved changes in VS Code are not picked up by Terraform.
  • Check that you're in the terraform/ directory when running the command. Terraform reads the .tf files in the current directory.

Why isn't Terraform detecting my replica change?

Now let's restore the replica count to its original value to demonstrate that Terraform detects changes in both directions.

  • In terraform/main.tf, find the line you just changed:
replicas = 3
  • Change 3 back to 2, so the line reads:
replicas = 2
  • Save main.tf and apply the change:
terraform apply
  • Type yes when prompted to confirm.

Terraform scales the deployment back down to 2 replicas. It detected the reverse change and applied only the replica diff, leaving everything else untouched.

✔️ Awesome, I've got everything!

Great. Double check you've saved terraform/main.tf with replicas set back to 2.

ⓧ I'd like to double check the full code

terraform {
  required_providers {
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.35"
    }
  }
  required_version = ">= 1.0"
}

provider "kubernetes" {
  config_path = "~/.kube/config"
}

resource "kubernetes_namespace" "devops_lab" {
  metadata {
    name = "devops-lab-tf"
  }
}

resource "kubernetes_deployment" "flask_app" {
  metadata {
    name      = "flask-app"
    namespace = kubernetes_namespace.devops_lab.metadata[0].name
    labels = {
      app = "flask-app"
    }
  }

  spec {
    replicas = 2

    selector {
      match_labels = {
        app = "flask-app"
      }
    }

    template {
      metadata {
        labels = {
          app = "flask-app"
        }
      }

      spec {
        container {
          image             = "devops-flask-app:latest"
          image_pull_policy = "Never"
          name              = "flask-app"

          port {
            container_port = 5000
          }
        }
      }
    }
  }
}

resource "kubernetes_service" "flask_service" {
  metadata {
    name      = "flask-service"
    namespace = kubernetes_namespace.devops_lab.metadata[0].name
  }

  spec {
    selector = {
      app = "flask-app"
    }

    type = "NodePort"

    port {
      port        = 5000
      target_port = 5000
      node_port   = 30090
    }
  }
}
Destroy all Terraform-managed resources

Terraform tracks everything it created in the state file. This means it can cleanly remove all managed resources with a single command. No hunting through dashboards or remembering which YAML files you applied.

  • Destroy all Terraform-managed resources by running:
terraform destroy
  • Terraform shows you a plan of everything it will delete (the namespace, deployment, and service). Type yes and press Enter to confirm.

Terraform removes all three resources in the correct order (service first, then deployment, then namespace).

  • Verify the resources are gone by running:
kubectl get all -n devops-lab-tf

You should see a message indicating the namespace no longer exists. Everything Terraform managed has been removed from your cluster.

What about the kubectl resources from Step 3?

The resources in the devops-lab namespace (created manually with kubectl apply) are still running. Terraform only manages resources it created. This is why Terraform uses a separate namespace (devops-lab-tf) to avoid conflicts.

You can clean up the manual resources separately with kubectl delete namespace devops-lab if you like.

What is the Terraform lifecycle?

You've now experienced the complete Terraform workflow that professionals use daily:

  • terraform init downloads providers and sets up the working directory.
  • terraform plan previews what changes will be made (safe to run anytime).
  • terraform apply creates or updates resources to match your configuration.
  • terraform destroy removes all managed resources cleanly.

This same workflow applies whether you're managing a local Kubernetes cluster or provisioning cloud infrastructure on AWS, Azure, or GCP.

Seeing an error during destroy?

  • If Terraform reports it cannot connect to the cluster, make sure Docker Desktop is still running with Kubernetes enabled.
  • If you accidentally deleted the terraform.tfstate file, Terraform no longer knows what it manages. You would need to delete the namespace manually with kubectl delete namespace devops-lab-tf.

Why is terraform destroy failing on my local Kubernetes cluster?

Secret mission

Scale Up and Add a Redis Microservice

Production systems rarely run a single service. Can you extend your Terraform configuration to manage multiple containers and scale your Flask app to handle more traffic? This challenge simulates a real multi-service architecture.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally on Docker Desktop, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Kubernetes namespace devops-lab-tf (Terraform-managed, with Flask and Redis deployments).
  • Kubernetes namespace devops-lab (manually created with kubectl).
  • Docker image devops-flask-app:latest.
  • Local project files (app/, k8s/, terraform/ directories).
  • Terraform binary (terraform.exe added to PATH).

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with Terraform and Kubernetes.

  • Your Kubernetes pods continue running inside Docker Desktop.
  • The Terraform binary stays on your PATH for future projects.
  • All project files remain on your machine ready for further edits.

Pause - I'll come back to this later

Shut down running resources to free up memory, but keep all your files intact so you can resume later.

  • Quit Docker Desktop. This stops all Kubernetes pods and frees up system memory.
  • Your project files, Terraform state, and Docker image remain on disk.
  • When you return, open Docker Desktop, wait for Kubernetes to start, and run terraform apply from your terraform/ directory to bring everything back up.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps in order.

  • Destroy all Terraform-managed Kubernetes resources by running this from your terraform/ directory:
cd terraform
terraform destroy
  • Type yes when prompted to confirm.
  • Delete the manually created Kubernetes namespace from Step 3:
kubectl delete namespace devops-lab
  • Remove the Docker image:
docker rmi devops-flask-app:latest
  • Delete all local project files. On macOS or Linux:
cd ~/Desktop
rm -rf app k8s terraform

On Windows (PowerShell):

cd ~\Desktop
Remove-Item -Recurse -Force app, k8s, terraform
  • Remove the Terraform binary. Delete the terraform.exe file from the folder where you extracted it, then remove that folder from your system PATH.

Nice Work!

Nice Work!

You just built a complete DevOps workflow from scratch. You containerized a Python web application, deployed it to Kubernetes, and managed the entire infrastructure declaratively with Terraform.

You've learned how to:

  • Build a custom Docker image from a Dockerfile and run it as a containerized Flask application.
  • Deploy to Kubernetes manually with kubectl, then rewrite the entire deployment as Terraform Infrastructure as Code.
  • Run the full Terraform lifecycle (init, plan, apply, destroy) and see how Terraform detects drift and applies only the necessary changes.
  • Extended the architecture with a second microservice (Redis), managed declaratively through Terraform to simulate a real multi-service production setup.

Ready to quiz yourself?