Platform Engineering Starter Kit Series: Repository Scaffolding & Minimal Infra Bootstrap (2)
In the first post, Introduction and Prerequisites (1), we prepared your workstation and created a baseline repo.
Now we’ll:
- Scaffold Terraform modules and per-env folders (dev/staging/prod)
- Add a remote backend for Terraform state (GCS)
- Enable required GCP APIs in Terraform
- Create a minimal VPC with secondary ranges for GKE
- Stand up a GKE cluster with sensible defaults (autoscaling, release channel)
- Add a Makefile with environment switching, validation, and kubeconfig helpers
0. Starting Point: Get the Post 1 Code
If you completed Post 1:
git checkout -b post-02-repo-scaffolding-infraIf you skipped Post 1:
git clone -b post-01-intro-prerequisites https://github.com/JiminByun0101/platform-starter-kit.git
cd platform-starter-kit
git checkout -b post-02-repo-scaffolding-infra1. Makefile (with env switching + quality gates)
We’ll make it easy to run Terraform and gcloud commands without long copy-paste strings.
Create Makefile at repo root:
Note: Medium’s code blocks may alter indentation. For accurate formatting, copy the file directly from the GitHub repository instead of this post.
# Default environment (override: make ENV=staging plan)
ENV ?= dev
# Load .env.<ENV> if it exists
ifneq (,$(wildcard .env.$(ENV)))
include .env.$(ENV)
export
endif
TF_DIR=terraform/gcp/envs/$(ENV)
.PHONY: help init fmt validate plan apply destroy kubeconfig apis whoami tfstate
help: ## Show targets
@echo "ENV=$(ENV)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS=":.*?## "}; {printf "\033[36m%-18s\033[0m %s\n", $$1, $$2}'
whoami: ## Show gcloud identity and project
gcloud config list
gcloud auth list
apis: ## (One-time) Enable required GCP APIs (idempotent)
gcloud services enable container.googleapis.com compute.googleapis.com artifactregistry.googleapis.com \
cloudresourcemanager.googleapis.com iamcredentials.googleapis.com serviceusage.googleapis.com \
cloudbuild.googleapis.com secretmanager.googleapis.com
fmt: ## Terraform fmt
cd $(TF_DIR) && terraform fmt -recursive
fmt-all: ## Terraform fmt all envs and modules
terraform -chdir=terraform/gcp fmt -recursive
validate: ## Terraform validate
cd $(TF_DIR) && terraform init -backend=false && terraform validate
init: ## Terraform init (uses backend)
cd $(TF_DIR) && terraform init
reinit: ## Reinitialize backend after changing bucket/prefix
cd $(TF_DIR) && terraform init -reconfigure
plan: validate ## Terraform plan with tfvars
cd $(TF_DIR) && terraform plan -var-file=$(ENV).tfvars
apply: ## Terraform apply with tfvars
cd $(TF_DIR) && terraform apply -auto-approve -var-file=$(ENV).tfvars
destroy: ## Terraform destroy (careful!)
cd $(TF_DIR) && terraform destroy -auto-approve -var-file=$(ENV).tfvars
kubeconfig: ## Fetch kubeconfig for the current env cluster
gcloud container clusters get-credentials $(CLUSTER_NAME) --region $(REGION) --project $(PROJECT_ID)Tip: run
make helpto see targets. Switch envs withENV=staging make plan.Learn More about Terraform CLI: https://developer.hashicorp.com/terraform/cli/commands
2. Remote State Backend (GCS)
Terraform needs to remember what it has built so it can update or destroy it later.
It stores this information in a state file (terraform.tfstate).
By default, that file lives on your computer — but that’s risky because:
- Only you can see it (teammates can’t work from the same state)
- If your laptop dies or the file is deleted, the state is lost
- If two people run Terraform at the same time, the file can get corrupted
Solution: use a remote backend in Google Cloud Storage (GCS) with locking and versioning.
2.1 Create the bucket (one-time):
Bucket names are globally unique. Add a small personal tag so readers don’t collide with your example.
# vars from your .env.dev (or any env)
PROJECT_ID=platform-starter-kit
REGION=northamerica-northeast1
# Name must be globally unique
# Check if bucket already exists before creating
if ! gsutil ls gs://${TF_STATE_BUCKET} > /dev/null 2>&1; then
echo "Creating bucket gs://${TF_STATE_BUCKET}..."
gcloud storage buckets create gs://${TF_STATE_BUCKET} \
--project ${PROJECT_ID} \
--location ${REGION} \
--uniform-bucket-level-access
echo "Bucket created."
else
echo "Bucket gs://${TF_STATE_BUCKET} already exists."
fi
# Optional: make it versioned
gcloud storage buckets update gs://${TF_STATE_BUCKET} --versioning2.2 Use the bucket in Terraform
We won’t hardcode the state file path — instead, we’ll tell Terraform to use this bucket as its backend in each environment’s main.tf:
(You don’t need to do this now)
Example:
terraform {
backend "gcs" {
bucket = "platform-starter-kit-jimin-tf-state" # your bucket name
prefix = "envs/dev" # subfolder for this env’s state
}
}bucket→ The Cloud Storage bucket where the state will be keptprefix→ A folder path inside the bucket for this environment’s state files (dev,staging,prod)
3. Enable Required APIs (one-time per project)
We can enable required GCP APIs manually or in Terraform.
For now, let’s enable them with make apis:
3.1 Verify you’re on the right account & project (console output shown)
# Show the active account and project
gcloud auth list
gcloud config list --format='value(core.account,core.project)'Expected output example:
* jimin@real-smile.com
jimin@real-smile.com platform-starter-kitInstall GNU Make if you don’t have it:
# Ubuntu / WSL2
sudo apt-get update && sudo apt-get install -y make
# macOS (Homebrew)
brew install makeThen run:
make apisThis runs:
- Kubernetes Engine API
- Compute Engine API
- Artifact Registry API
- Cloud Resource Manager API
- IAM Service Account Credentials API
- Service Usage API
- Cloud Build API
- KMS API
- Secret Manager API
Important: If you skip this, Terraform will fail when creating GKE or other resources.
4. Terraform Code Structure: Common Modules + Environment Configs
Terraform code is split into two layers:
- Modules (
modules/vpc,modules/gke) — reusable building blocks - Environment configs (
envs/dev,envs/staging,envs/prod) — specific settings per env
4.1 Repository Layout
platform-starter-kit/
├── Makefile
├── .env.sample
├── terraform/
│ └── gcp/
│ ├── backend/ # one-time remote state (GCS bucket)
│ │ └── main.tf
│ ├── modules/
│ │ ├── vpc/
│ │ │ ├── main.tf
│ │ │ ├── variables.tf
│ │ │ └── outputs.tf
│ │ └── gke/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── envs/
│ ├── dev/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── dev.tfvars
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── staging.tfvars
│ └── prod/
│ ├── main.tf
│ ├── variables.tf
│ └── prod.tfvars
└── docs/4.2 VPC Module
Creates a custom VPC, subnet, and secondary ranges for GKE pods & services.
terraform/gcp/modules/vpc/variables.tf
variable "project_id" { type = string }
variable "region" { type = string }
variable "network_name" { default = "psk-vpc" }
variable "subnet_name" { default = "psk-subnet" }
variable "subnet_cidr" { default = "10.10.0.0/24" }
variable "pods_cidr" { default = "10.20.0.0/16" }
variable "services_cidr"{ default = "10.30.0.0/20" }terraform/gcp/modules/vpc/main.tf
resource "google_compute_network" "vpc" {
project = var.project_id
name = var.network_name
auto_create_subnetworks = false
}
resource "google_compute_subnetwork" "subnet" {
project = var.project_id
name = var.subnet_name
ip_cidr_range = var.subnet_cidr
region = var.region
network = google_compute_network.vpc.id
private_ip_google_access = true
secondary_ip_range {
range_name = "pods"
ip_cidr_range = var.pods_cidr
}
secondary_ip_range {
range_name = "services"
ip_cidr_range = var.services_cidr
}
}terraform/gcp/modules/vpc/outputs.tf
output "network" {
description = "The name of the VPC network"
value = google_compute_network.vpc.name
}
output "subnet" {
description = "The name of the subnet"
value = google_compute_subnetwork.subnet.name
}4.3 GKE Module
Creates a GKE cluster & default node pool with autoscaling.
terraform/gcp/modules/gke/variables.tf
variable "project_id" { type = string }
variable "region" { type = string }
variable "cluster_name" { type = string }
variable "network" { type = string }
variable "subnet" { type = string }
variable "min_nodes" { default = 1 }
variable "max_nodes" { default = 3 }
variable "machine_type" { default = "e2-standard-2" }
variable "spot_nodes" { default = true }terraform/gcp/modules/gke/main.tf
resource "google_container_cluster" "this" {
name = var.cluster_name
location = var.region
project = var.project_id
network = var.network
subnetwork = var.subnet
remove_default_node_pool = true
initial_node_count = 1
deletion_protection = false
ip_allocation_policy {
cluster_secondary_range_name = "pods"
services_secondary_range_name = "services"
}
release_channel { channel = "REGULAR" }
enable_shielded_nodes = true
}
resource "google_container_node_pool" "default" {
name = "${var.cluster_name}-pool"
cluster = google_container_cluster.this.name
location = var.region
project = var.project_id
node_config {
machine_type = var.machine_type
spot = var.spot_nodes
oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"]
metadata = { disable-legacy-endpoints = "true" }
}
autoscaling {
min_node_count = var.min_nodes
max_node_count = var.max_nodes
}
management {
auto_upgrade = true
auto_repair = true
}
}terraform/gcp/modules/gke/outputs.tf
output "name" {
description = "The name of the GKE cluster"
value = google_container_cluster.this.name
}
output "endpoint" {
description = "The endpoint of the GKE cluster"
value = google_container_cluster.this.endpoint
}
output "node_pool_name" {
description = "The name of the default node pool"
value = google_container_node_pool.default.name
}4.4 Dev Environment Config
Links the modules with project/env-specific values.
terraform/gcp/envs/dev/variables.tf
variable "project_id" { type = string }
variable "region" { type = string }
variable "cluster_name" { type = string }terraform/gcp/envs/dev/dev.tfvars
project_id = "platform-starter-kit"
region = "northamerica-northeast1"
cluster_name = "psk-dev-gke"terraform/gcp/envs/dev/main.tf
terraform {
backend "gcs" {
bucket = "platform-starter-kit-jimin-tf-state" # your bucket name
prefix = "envs/dev"
}
}
provider "google" {
project = var.project_id
region = var.region
}
module "vpc" {
source = "../../modules/vpc"
project_id = var.project_id
region = var.region
}
module "gke" {
source = "../../modules/gke"
project_id = var.project_id
region = var.region
cluster_name = var.cluster_name
network = module.vpc.network
subnet = module.vpc.subnet
}5. Bootstrap the Infra
# Enable required APIs
make apis
# Init Terraform for dev env
make init
# Preview changes
make plan
# Apply changes
make apply
# Get kubeconfig
make kubeconfig
# Verify cluster access
kubectl get nodes5.1 Verification in GCP Console
Go to:
- VPC Network → VPC networks → you should see
psk-vpc
- Kubernetes Engine → Clusters → you should see your GKE cluster
psk-dev-gke
5.2 Fetch kubeconfig
make kubeconfig
kubectl get nodesIf you see the gke-gcloud-auth-plugin warning, install the plugin based on how you installed the Cloud SDK:
# Install gke-gcloud-auth-plugin if needed
# For Ubuntu/Debian (apt repo)
sudo apt-get update && sudo apt-get install -y google-cloud-sdk-gke-gcloud-auth-plugin
# For macOS (Homebrew)
brew upgrade google-cloud-sdk
# Enable plugin in shell
export USE_GKE_GCLOUD_AUTH_PLUGIN=TrueRe-run:
make kubeconfig
kubectl cluster-info
kubectl get nodes5.3 Cost Warning
GKE clusters incur costs even if idle.
If you’re not going straight into Post 3 within the hour, you should delete the resources to avoid surprise bills:
make destroyThis will remove the GKE cluster, VPC, and any associated resources created in this post. You can always recreate them later by re-running make apply.
6. Commit & Push
git add .
git commit -m "Post 02: Repo scaffolding + minimal VPC & GKE bootstrap"
git push -u origin post-02-repo-scaffolding-infraNext in the Series
In Post 3 we’ll:
- Install Argo CD in GKE
- Set up GitOps for a sample FastAPI + React app
- Prepare Helm charts for platform components
