Day 6 - Deploying a Web App on Google Kubernetes Engine (GKE) using Terraform & Docker
In this article, we will be deploying a simple Flask web application to Google Kubernetes Engine (GKE) using Terraform and Docker. We will leverage the power of containerization and Infrastructure as Code (IaC) to build a scalable and robust deployment pipeline.
The code for this article can be found in this GitHub repo under the Day_6 folder.
Prerequisites
Python 3.x
Google Cloud Project with the
Compute Admin
,Compute Network Admin
,Kubernetes Engine Admin
&Service Account User
roles granted to a service account.The Service Account Key JSON file downloaded locally.
gcloud CLI
installed locally on your computer (Instructions)Terraform (Instructions)
Docker (Instructions)
Setting up
Writing and building the app
On your computer, create a directory with the name Deploy-a-web-app
or a name of your choosing.
Inside the folder, create and activate the virtual environment that we’ll use to install our dependencies:
$ python -m venv venv # Create the virtual environment
# Activate the virtual environment
$ venv\Scripts\activate # For Windows
$ source venv/bin/activate # For MacOS/Linux
Install the dependencies that we’ll need & add them to the requirements.txt
file
$ pip install Flask requests gunicorn Werkzeug
# Add them to a requirements.txt file
$ pip freeze > requirements.txt
Proceed to create two folders in the Deploy-a-web-app
directory: templates
& static
In the templates folder, create a file called index.html and fill it with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask Demo Random Quote</title>
<style>
.theroot{
height: 100vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.container {
text-align: center;
}
</style>
</head>
<body>
<div class="theroot">
<div class="container">
<img src="{{ url_for('static', filename='images/dog.webp') }}"/>
<h2><b>Random Quote Generator</b></h2>
<p>✍️ <b>Author</b>: {{quote.author}}</p>
<p>📃 <b>Quote</b>: {{quote.quote}}</p>
</div>
</div>
</body>
</html>
Inside the static
folder, create a folder called images
. Inside the images folder, you can add an image of your choice. I added an image of a dog. Remember to edit the index.html to ensure the image name matches yours. i.e replace dog.webp
with your image name.
Proceed to create a file called main.py
in the Deploy-a-web-app
directory and fill it with the following code:
import requests
from flask import Flask, render_template
app = Flask(__name__)
@app.route("/")
def get_random_quote():
"""Fetch a random quote from the API"""
response = requests.get('https://dummyjson.com/quotes/random')
quote = response.json()
return render_template('index.html', quote=quote)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=8080)
Script explanation: The code above will be using the requests
module to fetch a random quote from the dummyjson
API and render that quote in a HTML template; this app will also run on port 8080
when executed.
Structure
Your directory structure should ideally look like this:
Deploy-a-web-app
|- static/
| |- images/
| |- dog.webp (or another image)
|
|- templates/
| |- index.html
|
|- main.py
Run the web app
In your terminal, navigate to the location of your main.py file and run the following command to launch the web app. Visit http://127.0.0.1:8080
on your browser to see your web app
$ python main.py
Creating our Dockerfile and .dockerignore files
In the same directory as our main.py file, create a file called Dockerfile
and fill it with the following:
FROM python:3.13-alpine
WORKDIR /usr/src/app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD [ "python", "./main.py" ]
Proceed to create a file called .dockerignore
and add the following inside it. Remember to include the period in the dockerignore filename
venv/ # or the name of the folder of your virtual environment
Explanation:
When building our Docker image, Docker will ignore the files/folders listed in the .dockerignore
file. We will ideally be ignoring the virtual environment folder from the image building process.
What is Docker?
Docker is a software platform that allows you to build, test, and deploy applications as containers quickly. You can read more about it here.
Our Dockerfile explanation:
This Dockerfile
builds a container image for a Python 3.13 application using a lightweight version called Alpine. It installs dependencies from requirements.txt
, copies the application code and its dependencies into the container, and sets the default command to run the main.py
script within the container. This approach ensures a consistent and reproducible environment for your Python application across different systems.
Updated Structure:
Your structure should now look like this with the Dockerfile
and .dockerignore
added:
Deploy-a-web-app
|- static/
| |- images/
| |- dog.webp (or another image)
|
|- templates/
| |- index.html
|
|- main.py
|- Dockerfile #------> NEW
|- .dockerignore #------> NEW
Building our Docker image
Go back to your terminal and navigate to the same directory as main.py
or Dockerfile
and run the following. The below command will build a docker image called my-web-app
. You can replace my-web-app
with the image name of your choice:
$ docker build -t my-web-app .
After it’s done building, you can view it in the present images in your system by running the following command:
$ docker image list
Storing our Docker container image in Artifact Registry
Assuming you already setup a Google Cloud Project, a Service Account with the necessary roles and installed & set up gcloud CLI
locally as earlier advised, we will proceed to create a Docker repository in our Google Cloud Project with the following command below.
Remember to replace:
my-docker-repo
- replace with the name of your repo.us-east1
- replace with the location of your choice.“My Docker repository”
- replace with the description of your choicePROJECT
- replace with your Google Cloud Project ID
$ gcloud artifacts repositories create my-docker-repo --repository-format=docker \
--location=us-east1 --description="My Docker repository" --project=PROJECT
You can confirm that your repository was created by listing out the repositories in the Google Cloud project. Replace PROJECT
with the GCP Project ID:
$ gcloud artifacts repositories list --project=PROJECT
Configure authentication
Before we push our image to the Artifact Registry, we need to configure Docker to use the gcloud CLI
to authenticate our requests. Since I used us-east1 as my location, my command would look as shown below. Make sure to match yours accordingly.
$ gcloud auth configure-docker us-east1-docker.pkg.dev
When prompted to save the credentials in the Docker configuration, type Y
and hit Enter.
Adding the image to our repository
Tag the image with the registry name
Tagging the Docker image with the repository name configures the docker push command to push the image to a specific location. Here is the command you can use to tag the image. Replace the missing bits with the ones you configured earlier on:
$ docker tag <YOUR-LOCAL-IMAGE-NAME>:latest \
<LOCATION>-docker.pkg.dev/<PROJECT ID>/<REPOSITORY-NAME>/<YOUR-LOCAL-IMAGE-NAME>:tag1
For example, mine looked like this:
$ docker tag my-web-app:latest \
us-east1-docker.pkg.dev/my_project_id_1234/my-docker-repo/my-web-app:tag1
Push the image to Artifact Registry
$ docker push <LOCATION>-docker.pkg.dev/<PROJECT ID>/<REPOSITORY-NAME>/<YOUR-LOCAL-IMAGE-NAME>:tag1
Confirm the existence of your repository and pushed image when you visit Google Cloud Console and search for Artifact Registry. Click on Repositories
Creating our Terraform Configuration
Go back to your code editor inside your Deploy-a-web-app
folder, and create a file called app.tf
and fill it with the following. Remember to replace the missing bits with your values as configured:
terraform {
required_providers {
google = {
source = "hashicorp/google"
version = "6.8.0"
}
}
}
provider "google" {
# Configure authentication using a service account key file
credentials = file("./<CREDENTIALS-FILE>.json") # Point this to where your credentials are locally
region = "us-east1" # Replace with desired region
project = "PROJECT_ID" # Replace with your project id
}
data "google_client_config" "default" {}
provider "kubernetes" {
host = "https://${google_container_cluster.default.endpoint}"
token = data.google_client_config.default.access_token
cluster_ca_certificate = base64decode(google_container_cluster.default.master_auth[0].cluster_ca_certificate)
ignore_annotations = [
"^autopilot\\.gke\\.io\\/.*",
"^cloud\\.google\\.com\\/.*"
]
}
resource "kubernetes_deployment_v1" "default" {
metadata {
name = "my-web-app-deployment"
}
spec {
selector {
match_labels = {
app = "my-web-app"
}
}
template {
metadata {
labels = {
app = "my-web-app"
}
}
spec {
container {
image = "<LOCATION>-docker.pkg.dev/<PROJECT_ID>/<REPOSITORY_NAME>/<IMAGE_NAME>:tag1" # Replace with the actual image name
name = "my-web-app-container"
port {
container_port = 8080
name = "my-web-app-svc"
}
security_context {
allow_privilege_escalation = false
privileged = false
read_only_root_filesystem = false
capabilities {
add = []
drop = ["NET_RAW"]
}
}
liveness_probe {
http_get {
path = "/"
port = "my-web-app-svc"
http_header {
name = "X-Custom-Header"
value = "Awesome"
}
}
initial_delay_seconds = 3
period_seconds = 3
}
}
security_context {
run_as_non_root = true
seccomp_profile {
type = "RuntimeDefault"
}
}
# Toleration is currently required to prevent perpetual diff:
# https://github.com/hashicorp/terraform-provider-kubernetes/pull/2380
toleration {
effect = "NoSchedule"
key = "kubernetes.io/arch"
operator = "Equal"
value = "amd64"
}
}
}
}
}
resource "kubernetes_service_v1" "default" {
metadata {
name = "my-web-app-loadbalancer"
annotations = {
}
}
spec {
selector = {
app = kubernetes_deployment_v1.default.spec[0].selector[0].match_labels.app
}
ip_family_policy = "RequireDualStack"
port {
port = 80
target_port = kubernetes_deployment_v1.default.spec[0].template[0].spec[0].container[0].port[0].name
}
type = "LoadBalancer"
}
depends_on = [time_sleep.wait_service_cleanup]
}
# Provide time for Service cleanup
resource "time_sleep" "wait_service_cleanup" {
depends_on = [google_container_cluster.default]
destroy_duration = "180s"
}
Script explanation:
This Terraform script sets up a Kubernetes deployment and service on GCP using Google Kubernetes Engine (GKE). The google
provider is configured with authentication via a service account key file, a specified region i.e us-east1
, and a project ID. The Kubernetes
provider uses the GKE cluster’s endpoint, access token, and CA certificate to manage the resources.
The script creates a Kubernetes deployment named my-web-app-deployment
, which runs a container based on the Docker image we stored in Artifact Registry earlier on. It configures the container with security settings and a liveness probe for health checks. Additionally, a toleration is added to handle architecture-specific scheduling. The deployment is exposed via a Kubernetes service named my-web-app-loadbalancer
, which is configured as a load balancer to distribute traffic to the app.
The service supports dual-stack IPs and maps external traffic on port 80
to the container’s port 8080
. To ensure smooth resource cleanup, a time_sleep
resource introduces a delay before service destruction.
Proceed to create another file in the same directory and name it cluster.tf
Remember to replace the values to match your configurations.
resource "google_compute_network" "default" {
name = "myclusternetwork"
auto_create_subnetworks = false
enable_ula_internal_ipv6 = true
}
resource "google_compute_subnetwork" "default" {
name = "myclustersubnetwork"
ip_cidr_range = "10.0.0.0/16"
region = "us-east1" # Select your desired region
stack_type = "IPV4_IPV6"
ipv6_access_type = "EXTERNAL" # This is "EXTERNAL" since we're creating an external loadbalancer
network = google_compute_network.default.id
secondary_ip_range {
range_name = "services-range"
ip_cidr_range = "192.168.0.0/24"
}
secondary_ip_range {
range_name = "pod-ranges"
ip_cidr_range = "192.168.1.0/24"
}
}
resource "google_container_cluster" "default" {
name = "my-web-app-cluster"
location = "us-east1" # Select your desired region
enable_autopilot = true
enable_l4_ilb_subsetting = true
network = google_compute_network.default.id
subnetwork = google_compute_subnetwork.default.id
ip_allocation_policy {
stack_type = "IPV4_IPV6"
services_secondary_range_name = google_compute_subnetwork.default.secondary_ip_range[0].range_name
cluster_secondary_range_name = google_compute_subnetwork.default.secondary_ip_range[1].range_name
}
# Set `deletion_protection` to `true` will ensure that one cannot
# accidentally delete this instance by use of Terraform.
deletion_protection = false
}
Script explanation:
This Terraform script provisions a Google Kubernetes Engine (GKE) cluster with a custom network and subnetwork configuration in your GCP project. The script begins by creating a VPC network i.e myclusternetwork
with auto_create_subnetworks
set to false
, ensuring custom subnetworks are used instead of automatically created ones. It enables IPv6 for internal communication.
Next, it creates a custom subnetwork i.e myclustersubnetwork
in the region you selected i.e us-east1
, with an IPv4 CIDR range of 10.0.0.0/16
and dual-stack support for both IPv4 and external IPv6 traffic. The subnetwork includes two secondary IP ranges: one for Kubernetes services (services-range
) and another for pods (pod-ranges
).
The GKE cluster my-web-app-cluster
is created in the same region with Autopilot mode enabled for automated cluster management. It is configured to use the custom network and subnetwork, with IP allocation policies that associate the secondary IP ranges with Kubernetes services and pods. The enable_l4_ilb_subsetting
feature is enabled to optimize internal load balancer routing. The script also includes deletion_protection
, set to false
, allowing the cluster to be deleted using Terraform if needed.
In your terminal, navigate to the same directory as the app.tf
and cluster.tf
files and initialize Terraform:
$ terraform init
Next, run terraform plan
to plan and review the resources/changes that will be applied to your GCP project
$ terraform plan
Run terraform apply
to apply the changes
$ terraform apply
The command will take some time to deploy the resources on your GCP Project and get everything working.
Once the deployment is done, you can navigate to Kubernetes Engine on your Google Cloud Console and click on Clusters to confirm your newly created cluster
On the left panel in Kubernetes Engine page, click on Workloads to confirm your new deployment:
View your running web app
Click on the deployment name; this will take you to the deployment details page. Scroll to the bottom to the section called Exposing Services.
Here is where you’ll find the endpoints for your Load Balancer that will direct traffic to your web app.
Click on the endpoint IP address. This will open your web app on a new tab
Conclusion
Deploying a web application to GKE using Docker, Terraform, gcloud CLI to GCP demonstrates the power of modern cloud-native tools to streamline and automate the deployment process. Docker ensures consistent and portable containerized applications, while Terraform enables infrastructure as code (IaC) for reliable and repeatable deployments. The gcloud CLI simplifies interaction with Google Cloud services, making it easier to manage Kubernetes clusters and resources. By combining these tools, we not only achieve a scalable and resilient deployment but also lay a solid foundation for implementing DevOps best practices.