Namaste • Hello • Moi — I’m Biswa

Building, learning, and writing as I go.

← Back to all posts

Infrastructure as Code: Deploying Hugo to Google Cloud Run with Terraform

Word cloud for Infrastructure as Code: Deploying Hugo to Google Cloud Run with Terraform

When I decided to move my blog to Google Cloud Platform (GCP), I didn’t just want to manually click around the console. I wanted a reproducible, version-controlled infrastructure—commonly known as Infrastructure as Code (IaC).

In this post, I’ll walk you through how I set up a robust deployment pipeline for this Hugo site using Terraform and Google Cloud Run.

Why Terraform?

Terraform allows you to define your cloud resources in declarative configuration files. This offers several huge benefits:

  1. Consistency: No more “it works on my machine” or forgetting which checkbox you clicked in the console.
  2. Version Control: Your infrastructure lives in Git alongside your code. You can track changes, rollback, and review infrastructure updates just like software features.
  3. Automation: Deploying a whole new environment (staging, production) becomes a single command.

The Architecture

For this blog, the infrastructure is simple yet powerful:

  • Google Artifact Registry: Stores the Docker container images for the blog.
  • Google Cloud Run: a serverless platform that runs the stateless container.
  • Terraform: Orchestrates the creation of these resources and the deployment process.

The Setup

1. Variables and Configuration

To keep secrets safe and the code reusable, I use variables. I strictly avoid hardcoding project IDs or sensitive info in the code.

variables.tf:

variable "project_id" {
  description = "The Google Cloud project ID."
  type        = string
}

variable "region" {
  description = "The GCP region (e.g., europe-north1)."
  type        = string
}

variable "service_name" {
  description = "The name of the Cloud Run service."
  type        = string
}

variable "repository_id" {
  description = "The ID of the Artifact Registry repository."
  type        = string
}

I configure these using a terraform.tfvars file (which is git-ignored!), but here is what the example looks like:

terraform.tfvars.example:

project_id    = "your-project-id"
region        = "europe-north1"
service_name  = "blog-app"
repository_id = "blog-repo"

2. Artifact Registry

Container Registry (GCR) is deprecated, so I use the modern Artifact Registry. Terraform handles enabling the API and creating the repository with lifecycle policies to save costs.

# Enable API
resource "google_project_service" "artifact_registry" {
  service = "artifactregistry.googleapis.com"
}

# Create Repository
resource "google_artifact_registry_repository" "default" {
  location      = var.region
  repository_id = var.repository_id
  description   = "Docker repository for ${var.service_name}"
  format        = "DOCKER"

  # Keep only the last 5 tagged versions to save storage
  cleanup_policies {
    id     = "keep-last-5-versions"
    action = "KEEP"
    most_recent_versions {
      package_name_prefixes = ["${var.service_name}"]
      keep_count            = 5
    }
  }
}

3. The Build & Deploy Flow

This is the cool part. Typically, you might use a separate CI/CD tool (like Cloud Build triggers) to build your image. But for simplicity, I verified a flow where Terraform triggers a local gcloud build command.

This null_resource acts as a bridge. It calculates a timestamp to tag the image uniquely every time you deploy, ensuring Cloud Run always picks up the new version.

locals {
  timestamp = formatdate("YYYYMMDDhhmmss", timestamp())
  image_path = "${var.region}-docker.pkg.dev/${var.project_id}/${var.repository_id}/${var.service_name}:${local.timestamp}"
}

resource "null_resource" "build_image" {
  provisioner "local-exec" {
    # Builds the image locally and pushes to Artifact Registry
    command = "gcloud builds submit --tag ${local.image_path} .."
  }
  
  triggers = {
    always_run = "${timestamp()}"
  }
}

4. Cloud Run Service

Finally, the google_cloud_run_service resource defines the actual running application.

resource "google_cloud_run_service" "default" {
  name     = var.service_name
  location = var.region

  template {
    spec {
      containers {
        image = local.image_path # Uses the dynamic image tag from above
      }
    }
  }

  traffic {
    percent         = 100
    latest_revision = true
  }

  depends_on = [null_resource.build_image]
}

We also explicitly allow public access so the world can see the blog:

resource "google_cloud_run_service_iam_member" "public_access" {
  service  = google_cloud_run_service.default.name
  location = google_cloud_run_service.default.location
  role     = "roles/run.invoker"
  member   = "allUsers"
}

How Deployment Works

With this setup, deployment is incredibly streamlined:

  1. Code: I write a new blog post (like this one!) in Markdown.
  2. Plan: I run terraform plan to preview changes. Terraform sees that the always_run trigger has changed (due to the timestamp) and plans to rebuild the image.
  3. Apply: I run terraform apply.
    • Terraform runs gcloud builds submit, which packages my Hugo site into a Docker container and pushes it to Artifact Registry.
    • Terraform updates the Cloud Run service to point to this new image tag.
    • Cloud Run spins up a new revision and migrates traffic to it.

All of this happens in minutes, with zero downtime, and fully automated resource management.

Conclusion

Using Terraform for a personal blog might seem like overkill, but the peace of mind and strict control it gives over your cloud resources is invaluable. Plus, it’s a great way to practice IaC principles that scale up to enterprise systems.