Platform Engineering
Building the Developer Experience That Ships Software Faster
Platform engineering is the discipline of building and operating internal developer platforms that reduce cognitive load, eliminate toil, and accelerate software delivery. This course covers the full spectrum: from foundational concepts and Internal Developer Platforms to CI/CD architecture, Infrastructure as Code, Kubernetes in production, observability, SRE practices, build systems, feature flags, and zero-downtime database migrations. Everything a senior engineering leader needs to build world-class developer platforms.
What Makes a Good IDP?
An Internal Developer Platform is the sum of all the technology and tooling that a platform team provides to developers. It's not a single product you install. It's an integrated set of capabilities that, taken together, create a self-service developer experience. The best IDPs share common characteristics: they are self-service (no tickets needed), opinionated but flexible (golden paths with escape hatches), composable (built from best-of-breed components), and observable (you can see what's happening at every stage).
Think of an IDP as having five layers: the developer portal (the UI), service catalog (what exists), golden paths (how to create things), infrastructure orchestration (how things are provisioned), and observability integration (how to see what's happening). Each layer builds on the ones below it.
Backstage Deep Dive
Backstage, originally developed at Spotify, is the most widely adopted open-source framework for building developer portals. It was born from Spotify's experience managing 2,000+ microservices with hundreds of engineering teams. The core problem: nobody could find anything. Who owns this service? Where are the docs? What's the deployment status? What APIs does it expose? Backstage answers all of these questions through a unified portal.
Architecture
Backstage is a React frontend with a Node.js backend. It uses a plugin architecture that's core to its extensibility. The backend connects to a PostgreSQL database for the software catalog and can integrate with virtually any external system through plugins.
The Software Catalog
The software catalog is Backstage's most important feature. It provides a centralized registry of all software components, APIs, resources, and their relationships. Every entity in the catalog is described by a YAML descriptor file that lives alongside the code:
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-service
description: Handles payment processing and billing
annotations:
github.com/project-slug: myorg/payment-service
backstage.io/techdocs-ref: dir:.
pagerduty.com/service-id: P1234AB
datadog.com/dashboard-url: https://app.datadoghq.com/dash/12345
tags:
- python
- grpc
- payments
links:
- url: https://runbooks.internal/payment-service
title: Runbook
icon: dashboard
spec:
type: service
lifecycle: production
owner: team-payments
system: billing
dependsOn:
- resource:payments-db
- component:user-service
providesApis:
- payment-api
consumesApis:
- stripe-apiThe Scaffolder (Golden Path Templates)
The Scaffolder lets platform teams define templates that developers use to create new services, libraries, or infrastructure. This is where "golden paths" come to life. Instead of copying an old repo and deleting half the files, developers fill out a form and get a fully configured, production-ready service.
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: python-grpc-service
title: Python gRPC Service
description: Creates a new Python gRPC service with CI/CD, observability, and database setup
spec:
owner: platform-team
type: service
parameters:
- title: Service Details
required: [name, owner, system]
properties:
name:
title: Service Name
type: string
pattern: '^[a-z][a-z0-9-]*$'
owner:
title: Owner Team
type: string
ui:field: OwnerPicker
system:
title: System
type: string
ui:field: EntityPicker
- title: Infrastructure
properties:
database:
title: Database Type
type: string
enum: [postgres, mysql, none]
default: postgres
steps:
- id: fetch-template
name: Fetch Skeleton
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
- id: publish
name: Create GitHub Repository
action: publish:github
input:
repoUrl: github.com?owner=myorg&repo=${{ parameters.name }}
- id: register
name: Register in Catalog
action: catalog:register
input:
repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
catalogInfoPath: /catalog-info.yamlBuild vs. Buy: The IDP Decision Matrix
Should you build your IDP on Backstage, buy a commercial solution like Port or Humanitec, or build something entirely custom? This decision depends on your team's capabilities, timeline, and specific needs.
| Factor | Backstage (Open Source) | Port / Cortex (Commercial) | Humanitec (Commercial) |
|---|---|---|---|
| Initial Setup Cost | High (significant engineering investment) | Low (SaaS, quick start) | Medium (integration needed) |
| Customizability | Unlimited (you own the code) | High (API-driven, flexible model) | Medium (opinionated platform) |
| Maintenance Burden | High (upgrades, plugins, hosting) | Low (managed service) | Low (managed service) |
| Software Catalog | Excellent (core feature) | Excellent (core feature) | Good (focused on infra) |
| Infrastructure Orchestration | Plugin-based (varies) | Good (scorecards, actions) | Excellent (core feature) |
| Best For | Large orgs with platform engineers | Mid-size orgs wanting quick wins | Orgs focused on infra self-service |
IDP Maturity Model
Not every organization starts at the same level. Here's a maturity model for IDP adoption:
- Level 0 - Ad Hoc: No standardized tooling. Each team manages its own CI/CD, infrastructure, and deployment. Knowledge lives in individual heads. New service creation takes days or weeks.
- Level 1 - Standardized: Common CI/CD pipeline exists. Basic deployment automation. Shared infrastructure templates. Service creation takes hours to a day.
- Level 2 - Self-Service: Developer portal exists. Golden path templates for common patterns. Self-service infrastructure provisioning. Service creation takes minutes.
- Level 3 - Optimized: Full IDP with software catalog, automated compliance, cost visibility, and integrated observability. Developer satisfaction is measured and improved. Service creation is measured in minutes with full production readiness.
- Level 4 - Autonomous: Platform capabilities are AI-augmented. Automated remediation, predictive scaling, intelligent routing. The platform continuously improves itself based on usage patterns.
Zalando's platform team serves 500+ engineering teams. Their IDP includes automated Kubernetes namespace provisioning, a service catalog with 2,000+ microservices, golden path templates for Java and Python services, integrated CI/CD with automated security scanning, and self-service database provisioning. New developers can deploy their first service to production within their first day. That's the benchmark.
The Service Catalog: Your Single Source of Truth
A service catalog answers the most fundamental questions in a microservices architecture: What services exist? Who owns them? What do they depend on? Are they healthy? Where are the docs? Without a catalog, these questions devolve into Slack threads, tribal knowledge, and archaeological digs through GitHub.
An effective service catalog must be automatically populated (not manually maintained), always up-to-date (synced from source control and runtime), relationship-aware (shows dependencies and dependents), and actionable (links directly to dashboards, runbooks, and deployment tools). If developers have to manually update the catalog, it will rot within weeks. Automate everything.
Golden Paths in Practice
A golden path is more than a template. It's a complete, supported workflow that takes a developer from "I have an idea" to "my service is running in production." A well-designed golden path includes: a project template with sensible defaults (logging, health checks, metrics instrumentation, Dockerfile, CI/CD config), automated repository creation with branch protection rules, CI/CD pipeline configuration, infrastructure provisioning (database, cache, message queue), observability setup (dashboards, alerts, SLO definitions), documentation scaffolding, and catalog registration. The key is that all of this happens in one click. The developer fills out a form (service name, team, language, database type) and everything else is automated.
The Modern CI/CD Landscape
CI/CD is the backbone of any platform. It's the system that transforms developer intent (a git push) into running software (a production deployment). Getting this right has an outsized impact on developer productivity. A slow or unreliable pipeline is a daily tax on every engineer in the organization.
The CI/CD landscape has consolidated significantly. GitHub Actions has become the dominant player for GitHub-hosted repositories, with GitLab CI leading for GitLab shops. Jenkins, while still widely deployed, is increasingly viewed as legacy. CircleCI and Travis CI have lost significant market share. The trend is clear: CI/CD is moving toward tighter integration with source control platforms.
GitHub Actions Deep Dive
GitHub Actions is the most popular CI/CD platform for good reason: it's integrated directly into GitHub, has a massive marketplace of pre-built actions, supports matrix builds, reusable workflows, and composite actions, and offers generous free-tier resources.
Production-Ready Workflow
name: Build, Test & Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- run: pip install -r requirements.txt -r requirements-test.txt
- run: pytest --cov=src --cov-report=xml -v
- uses: codecov/codecov-action@v4
if: matrix.python-version == '3.12'
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
severity: 'CRITICAL,HIGH'
exit-code: '1'
build-and-push:
needs: [test, security-scan]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image-digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v5
with:
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
deploy-staging:
needs: build-and-push
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- run: |
# Update Kubernetes manifest with new image tag
sed -i "s|image:.*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}|" \
k8s/staging/deployment.yaml
- uses: azure/k8s-set-context@v4
with:
kubeconfig: ${{ secrets.KUBE_CONFIG_STAGING }}
- run: kubectl apply -f k8s/staging/Composite Actions vs Reusable Workflows
GitHub Actions provides two mechanisms for code reuse, and understanding the distinction is important for platform teams. Composite actions combine multiple steps into a single action that can be referenced from any workflow. They're ideal for encapsulating a common sequence -- like "set up our Python environment with the right version, install dependencies, configure our internal PyPI mirror." Reusable workflows are entire workflow definitions that can be called from other workflows. They're better for standardizing complete pipelines -- like "our standard Python CI pipeline with linting, testing, security scanning, and artifact publishing."
The key architectural difference: composite actions run inline within the calling job (sharing the same runner, filesystem, and environment variables), while reusable workflows run as separate jobs (with their own runner and isolated filesystem). For platform teams, the typical pattern is to provide reusable workflows for standard pipelines and composite actions for common setup sequences.
name: 'Setup Python Environment'
description: 'Standard Python setup with internal PyPI and caching'
inputs:
python-version:
description: 'Python version to use'
default: '3.12'
runs:
using: 'composite'
steps:
- uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
cache: 'pip'
- shell: bash
run: |
pip config set global.index-url https://pypi.internal.myorg.com/simple/
pip config set global.trusted-host pypi.internal.myorg.com
pip install -r requirements.txt
- shell: bash
run: echo "PYTHONPATH=${{ github.workspace }}/src" >> $GITHUB_ENVReusable Workflows
One of the most powerful features of GitHub Actions for platform teams is reusable workflows. Instead of duplicating CI/CD configuration across hundreds of repositories, you define workflows once in a central repository and reference them from individual repos. This is how you enforce organizational standards without mandating them.
name: CI
on: [push, pull_request]
jobs:
ci:
uses: myorg/platform-workflows/.github/workflows/python-ci.yml@v2
with:
python-version: "3.12"
enable-security-scan: true
secrets: inheritDeployment Strategies
Choosing the right deployment strategy is a critical platform decision. Each strategy trades off between speed, safety, and complexity.
| Strategy | Rollback Speed | Risk | Complexity | Best For |
|---|---|---|---|---|
| Rolling Update | Minutes | Medium | Low | Stateless services, K8s default |
| Blue-Green | Seconds | Low | Medium | Critical services, instant rollback needed |
| Canary | Seconds | Very Low | High | High-traffic services, gradual validation |
| Progressive (Flagger) | Automatic | Very Low | High | Automated canary with metric-based promotion |
CI/CD Security: Supply Chain Protection
Your CI/CD pipeline is one of the most privileged systems in your organization. It has credentials to deploy to production, access to secrets, and the ability to modify running services. Securing it is non-negotiable.
The SolarWinds attack demonstrated that CI/CD pipelines are high-value targets. Any compromise in your build pipeline can inject malicious code into every artifact you produce. SLSA (Supply-chain Levels for Software Artifacts) provides a framework for hardening your pipeline. At minimum, aim for SLSA Level 2: automated build process with provenance attestation.
Pipeline Security Checklist
- Pin action versions by SHA, not by tag. Tags can be moved; SHAs cannot. Use
actions/checkout@abc123definstead ofactions/checkout@v4. - Use OIDC for cloud authentication instead of long-lived secrets. GitHub Actions supports OIDC federation with AWS, GCP, and Azure.
- Scan dependencies with Dependabot, Snyk, or Trivy in every PR.
- Sign artifacts with Sigstore/cosign. This creates verifiable provenance for your container images.
- Limit permissions using the
permissionskey. Default to read-only and explicitly grant write access only where needed. - Require code review for workflow file changes. A compromised workflow can exfiltrate secrets.
Monorepo CI Challenges
If your organization uses a monorepo (or is considering one), CI/CD becomes significantly more complex. The core challenge: when a PR modifies files in /services/payment/, you don't want to build and test every service in the repository. You need affected-target detection.
Tools like nx affected, Turborepo's pipeline caching, and Bazel's build graph analysis solve this problem at different levels of sophistication. The platform team's job is to provide this infrastructure so that individual teams don't need to think about it. A well-configured monorepo CI should build only what changed, cache everything that didn't, and run tests in parallel across as many machines as needed.
The practical challenge goes deeper than just "build what changed." You need to handle transitive dependencies -- if the shared payment-types library changes, every service that imports it must be rebuilt and retested. This requires building a dependency graph of your repository. Nx and Bazel do this natively. For other setups, you can build a lightweight dependency graph using tools like madge (JavaScript), pydeps (Python), or custom scripts that parse import statements. At Shopify, their monorepo CI system analyzes Ruby dependency trees to determine which test suites to run, reducing average CI time from 45 minutes to under 8 minutes.
#!/bin/bash
# Detect which services are affected by changes in a PR
# Uses git diff against the base branch
CHANGED_FILES=$(git diff --name-only origin/main...HEAD)
# Direct changes: services whose files were modified
DIRECT=$(echo "$CHANGED_FILES" | grep '^services/' | cut -d/ -f2 | sort -u)
# Shared library changes: trigger dependent services
if echo "$CHANGED_FILES" | grep -q '^libs/'; then
CHANGED_LIBS=$(echo "$CHANGED_FILES" | grep '^libs/' | cut -d/ -f2 | sort -u)
for lib in $CHANGED_LIBS; do
# Find services that depend on this library
grep -rl "\"@myorg/$lib\"" services/*/package.json | \
cut -d/ -f2 >> /tmp/affected
done
TRANSITIVE=$(sort -u /tmp/affected)
fi
# Infra changes: trigger everything
if echo "$CHANGED_FILES" | grep -qE '^(Dockerfile|.github/|infra/)'; then
echo "ALL"
exit 0
fi
echo "$DIRECT $TRANSITIVE" | tr ' ' '\n' | sort -uSecrets Management in CI/CD
Secrets in CI/CD pipelines are a common attack vector. The best practices have converged around a few key patterns. First, use OIDC federation instead of long-lived credentials. GitHub Actions can assume AWS IAM roles directly via OIDC, eliminating the need to store AWS access keys as repository secrets. Second, use environment-level secrets in GitHub Actions. A secret scoped to the "production" environment requires manual approval before a workflow can access it, creating a human gate for production deployments. Third, never echo secrets in logs -- GitHub Actions masks known secrets, but dynamic secrets assembled at runtime can leak. Fourth, rotate secrets regularly and use short-lived tokens wherever possible. A leaked CI secret that expires in 1 hour is far less dangerous than one that lasts forever.
Pipeline Performance Optimization
Pipeline speed directly correlates with developer productivity. Every minute added to a CI pipeline is multiplied by the number of PRs per day across the organization. At 100 PRs/day, a 5-minute improvement saves over 8 hours of developer wait time daily.
- Dependency caching: Cache pip, npm, Maven, and Gradle dependencies between runs. This alone can save 2-5 minutes per build.
- Docker layer caching: Use BuildKit cache mounts and GitHub Actions cache backend. Rebuilding only changed layers can reduce Docker builds from 10 minutes to 30 seconds.
- Test parallelization: Use pytest-xdist, Jest workers, or Go's built-in parallel testing. Split test suites across multiple CI runners.
- Selective testing: Only run tests for changed packages and their dependents. Tools like Nx and Bazel provide this natively.
- Self-hosted runners: For large organizations, self-hosted runners on dedicated hardware can be 2-5x faster than GitHub-hosted runners due to better CPU, more RAM, and local caching.
Elite performers (per DORA) deploy on demand with a lead time of less than one hour. Your CI pipeline should complete in under 10 minutes for the critical path (lint, test, build, security scan). If it takes longer, treat it as a platform bug and invest in optimization.
Why IaC Is Non-Negotiable
Infrastructure as Code is the practice of managing and provisioning infrastructure through machine-readable definition files rather than physical hardware configuration or interactive configuration tools. In 2026, this isn't a nice-to-have. It's table stakes. If your infrastructure isn't defined in code, it's defined by whoever clicked buttons in the AWS console last, and you'll never know exactly what state it's in.
IaC provides three critical capabilities: reproducibility (create identical environments), auditability (every change is a PR with review and history), and scalability (provision 100 environments as easily as one).
Terraform Deep Dive
Terraform remains the dominant IaC tool, with the largest ecosystem of providers and the deepest community knowledge. Despite HashiCorp's controversial BSL license change in 2023 (and the resulting OpenTofu fork), Terraform continues to be the default choice for most organizations.
State Management
Terraform state is the most important (and most dangerous) concept in the Terraform ecosystem. The state file is a JSON document that maps your Terraform configuration to real-world resources. It's how Terraform knows that aws_instance.web in your config corresponds to i-abc123def in AWS.
terraform {
backend "s3" {
bucket = "myorg-terraform-state"
key = "services/payment-service/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
kms_key_id = "alias/terraform-state"
}
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.40"
}
}
}A team at a major fintech company accidentally ran terraform destroy on their production state file. They had no state file backups, no S3 versioning enabled, and no DynamoDB locking. They lost their entire production database cluster. It took 14 hours to recover from backups, during which the platform was completely down. Always enable S3 versioning, DynamoDB locking, and KMS encryption on your state bucket. Always.
Module Design Patterns
Well-designed Terraform modules are the building blocks of a scalable IaC practice. A good module encapsulates a reusable infrastructure pattern with sensible defaults and clear input/output contracts.
resource "aws_db_instance" "main" {
identifier = "${var.service_name}-${var.environment}"
engine = "postgres"
engine_version = "16.2"
instance_class = var.instance_class
allocated_storage = var.allocated_storage
max_allocated_storage = var.allocated_storage * 3
storage_encrypted = true
kms_key_id = var.kms_key_arn
db_name = var.database_name
username = "admin"
password = random_password.master.result
multi_az = var.environment == "production"
backup_retention_period = var.environment == "production" ? 30 : 7
deletion_protection = var.environment == "production"
vpc_security_group_ids = [aws_security_group.rds.id]
db_subnet_group_name = aws_db_subnet_group.main.name
performance_insights_enabled = true
monitoring_interval = 60
monitoring_role_arn = var.monitoring_role_arn
tags = merge(var.tags, {
Service = var.service_name
Environment = var.environment
ManagedBy = "terraform"
})
}Workspace and Environment Strategy
Terraform workspaces are one of the most misunderstood features. They allow you to maintain multiple state files within the same configuration directory, typically used for managing identical infrastructure across environments (dev, staging, production). However, many teams have been burned by workspaces because they make it easy to accidentally apply production changes when you thought you were in dev.
The recommended pattern for most organizations is to use directory-based separation rather than workspaces. Have a separate directory (and therefore separate state file) for each environment. This is more verbose but eliminates an entire class of operator errors. Workspaces still make sense for certain patterns, like managing per-tenant infrastructure in a multi-tenant SaaS application, where the configuration is truly identical and the blast radius of a mistake is limited to a single tenant.
# Recommended: directory-based separation
infra/
modules/ # Shared modules
rds-postgres/
eks-cluster/
vpc/
environments/
dev/
main.tf # module "db" { source = "../../modules/rds-postgres" }
backend.tf # S3 key: "env/dev/terraform.tfstate"
terraform.tfvars
staging/
main.tf
backend.tf # S3 key: "env/staging/terraform.tfstate"
terraform.tfvars
production/
main.tf
backend.tf # S3 key: "env/production/terraform.tfstate"
terraform.tfvarsDrift Detection
Infrastructure drift occurs when the actual state of your cloud resources diverges from what's defined in your Terraform configuration. Someone clicked a button in the AWS console. An auto-scaling policy modified instance counts. A security team manually updated a security group. Drift is inevitable in organizations of any size, and ignoring it erodes the value of IaC entirely.
Set up scheduled terraform plan runs (via CI/CD or tools like Spacelift, env0, or Terraform Cloud) that detect drift and notify your team. At Shopify, infrastructure drift alerts are treated like production incidents -- if Terraform state doesn't match reality, someone investigates within 24 hours. The fix is either to update the Terraform config to match reality or to run terraform apply to force reality back to match the config.
Atlantis: PR-Based Terraform Workflows
Atlantis is an open-source tool that automates Terraform via pull requests. When a developer opens a PR that modifies Terraform files, Atlantis automatically runs terraform plan and posts the output as a PR comment. Reviewers can see exactly what infrastructure changes will be made before they approve. Once approved, a comment like atlantis apply executes the changes.
This workflow is powerful because it brings the same code review rigor to infrastructure changes that we already apply to application code. No more "I ran terraform apply from my laptop and something went wrong." Every change is planned, reviewed, and applied through a controlled process.
version: 3
automerge: false
parallel_plan: true
parallel_apply: false
projects:
- name: payment-service-prod
dir: infra/environments/production
workflow: production
apply_requirements:
- approved
- mergeable
- name: payment-service-dev
dir: infra/environments/dev
workflow: default
workflows:
production:
plan:
steps:
- run: terragrunt plan -input=false -out=$PLANFILE
apply:
steps:
- run: terragrunt apply $PLANFILEPulumi: Real Programming Languages for IaC
Pulumi takes a fundamentally different approach from Terraform: instead of a domain-specific language (HCL), you write infrastructure in TypeScript, Python, Go, or C#. This means you get loops, conditionals, functions, classes, type checking, IDE support, and the full power of a real programming language.
import pulumi
import pulumi_aws as aws
import pulumi_eks as eks
# Create a production-ready EKS cluster
cluster = eks.Cluster(
"platform-cluster",
vpc_id=vpc.id,
subnet_ids=[s.id for s in private_subnets],
instance_type="m6i.xlarge",
desired_capacity=3,
min_size=2,
max_size=10,
node_root_volume_size=100,
enabled_cluster_log_types=[
"api", "audit", "authenticator",
"controllerManager", "scheduler",
],
)
# Export the kubeconfig
pulumi.export("kubeconfig", cluster.kubeconfig)GitOps with ArgoCD
GitOps is the practice of using Git as the single source of truth for declarative infrastructure and applications. ArgoCD is the most popular GitOps operator for Kubernetes. It continuously monitors a Git repository and automatically synchronizes the desired state (defined in Git) with the actual state (running in the cluster).
Infrastructure Testing
Infrastructure code deserves the same testing rigor as application code. Terratest (Go-based) and the Terraform testing framework (built-in since 1.6) let you write automated tests that provision real infrastructure, validate it works, and tear it down. Policy-as-code tools like OPA (Open Policy Agent) and HashiCorp Sentinel let you define organizational policies (e.g., "all S3 buckets must have encryption enabled") and enforce them at plan time.
Choose Terraform if you need multi-cloud support, have a large community of HCL-literate engineers, or need the broadest provider ecosystem. Choose Pulumi if your teams are strong in TypeScript/Python and want full programming language capabilities. Choose CDK/CDKTF if you're AWS-heavy and want to use TypeScript with CloudFormation under the hood. Choose OpenTofu if you need open-source Terraform without BSL licensing concerns.
Kubernetes: The Operating System of the Cloud
Kubernetes has won the container orchestration war. It's the default platform for running containerized workloads at scale, and understanding how to operate it in production is essential for any platform engineer. But let's be clear: Kubernetes is not always the right answer. If you have fewer than 10 services, your team doesn't have K8s expertise, and you're running straightforward web applications, managed services like AWS ECS or even Heroku might be better choices. K8s shines when you have many services, need sophisticated scheduling, or require portable workloads across clouds.
Managed vs. Self-Hosted
Unless you're Google, run a managed Kubernetes service. EKS, GKE, and AKS handle the control plane, etcd management, upgrades, and HA for you. The operational burden of self-hosted Kubernetes is enormous and rarely justified.
| Feature | EKS (AWS) | GKE (Google) | AKS (Azure) |
|---|---|---|---|
| Control Plane Cost | $73/month per cluster | Free (Standard), $73 (Enterprise) | Free |
| Max Pods per Node | 110 (with VPC CNI: depends on instance) | 110 | 250 |
| Autopilot Mode | Fargate (serverless pods) | GKE Autopilot (fully managed) | Virtual Nodes |
| Upgrade Experience | Manual or managed (add-on) | Excellent (auto-upgrade channels) | Good (auto-upgrade) |
| Service Mesh | App Mesh (being deprecated) | Anthos Service Mesh (Istio) | Open Service Mesh |
| Best For | AWS-heavy shops | K8s-native, multi-cloud | Azure/Microsoft shops |
Resource Management
Misconfigured resource requests and limits are the number one cause of K8s performance issues and cost overruns. Every container in Kubernetes can specify CPU and memory requests (guaranteed minimum) and limits (hard maximum).
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-service
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: payment-service
containers:
- name: app
image: ghcr.io/myorg/payment-service:v2.3.1
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "1000m"
memory: "1Gi"
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: payment-db-credentials
key: passwordAutoscaling: HPA, VPA, and KEDA
HPA (Horizontal Pod Autoscaler) adds or removes pod replicas based on CPU, memory, or custom metrics. It's the default autoscaling mechanism and works well for stateless services. VPA (Vertical Pod Autoscaler) adjusts the CPU and memory requests of individual pods based on historical usage. It's useful for right-sizing, but be cautious in production since it restarts pods to apply changes. KEDA (Kubernetes Event-Driven Autoscaling) extends HPA with event-driven scaling based on external metrics like SQS queue depth, Kafka lag, or Prometheus queries. It's essential for queue-based workloads.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: payment-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: payment-service
minReplicas: 3
maxReplicas: 50
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # Wait 5 min before scaling down
policies:
- type: Percent
value: 10
periodSeconds: 60
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Pods
pods:
metric:
name: http_requests_per_second
target:
type: AverageValue
averageValue: "1000"A critical production lesson: always configure scaleDown stabilization windows. Without them, your pods will flap up and down in response to short traffic spikes. The behavior section lets you control how aggressively the HPA scales in each direction. Scale up quickly (double capacity every 60 seconds if needed) but scale down slowly (remove only 10% every 60 seconds, after a 5-minute stabilization window). This prevents premature scale-down during traffic fluctuations.
Namespace Strategy and Multi-Tenancy
Namespace design is an underappreciated platform decision. The two main approaches are namespace-per-team and namespace-per-service. Namespace-per-team is simpler to manage and aligns with organizational boundaries -- the payments team gets the payments namespace. Namespace-per-service provides better isolation but creates more administrative overhead. Most organizations start with namespace-per-team and graduate to namespace-per-service as they grow. Regardless of your choice, enforce resource quotas on every namespace. Without quotas, a single runaway service can starve the entire cluster of CPU or memory.
apiVersion: v1
kind: ResourceQuota
metadata:
name: team-payments-quota
namespace: payments
spec:
hard:
requests.cpu: "20"
requests.memory: 40Gi
limits.cpu: "40"
limits.memory: 80Gi
pods: "100"
persistentvolumeclaims: "20"
services.loadbalancers: "5"Helm Charts vs. Kustomize
Helm and Kustomize represent two philosophies for managing Kubernetes manifests. Helm uses Go templating to generate manifests from parameterized charts. It's powerful, has a massive ecosystem of community charts, and supports lifecycle management (install, upgrade, rollback). The downside: Helm templates can become unreadable spaghetti of {{ if .Values.something }} conditionals. Kustomize uses overlay-based patching -- you write plain YAML and apply patches for different environments. It's simpler, produces readable output, and is built into kubectl. The downside: complex customizations require many small patch files. The pragmatic choice for most platform teams: use Helm for third-party applications (ingress controllers, monitoring stacks, databases) where community charts save enormous effort, and Kustomize for your own applications where you control the base manifests.
Networking: Ingress and Service Types
Kubernetes networking is one of the most confusing aspects for newcomers. Every pod gets its own IP address. Pods can communicate with any other pod directly (flat networking). Services provide stable DNS names and load balancing across pods. But getting traffic from outside the cluster to your services requires an Ingress resource and an Ingress controller.
The three main Ingress controllers are NGINX Ingress Controller (the default, battle-tested, handles most use cases), Traefik (auto-discovers services, great for dynamic routing), and AWS ALB Ingress Controller (provisions actual AWS ALBs, best for AWS-native shops). For production, configure TLS termination at the ingress level using cert-manager with Let's Encrypt for automatic certificate provisioning. Use Network Policies to restrict pod-to-pod communication -- by default, any pod can talk to any other pod in the cluster, which is a significant security risk. At minimum, deny all ingress traffic by default and explicitly allow only the connections each service needs.
Service Mesh: Do You Actually Need One?
A service mesh (Istio, Linkerd) provides traffic management, observability, and security at the network layer without modifying application code. It sounds great in theory. In practice, it adds significant complexity, latency, and operational overhead. The honest answer: most organizations under 50 services don't need a service mesh. Start with standard K8s networking and add a mesh only when you have concrete requirements (mTLS everywhere, canary routing, circuit breaking) that can't be met by simpler solutions.
If you do need a mesh, Linkerd is the simpler choice. It's lightweight (Rust-based proxy), has minimal configuration, and provides the core features (mTLS, observability, traffic splitting) without the complexity of Istio. Istio is more powerful but significantly harder to operate -- its control plane has multiple components, configuration is verbose, and debugging mesh issues requires deep expertise. At Lyft, switching from Istio to Envoy-based direct configuration reduced their service mesh operational incidents by 70%.
K8s is overkill if: you have fewer than 5 services, your team has no container experience, you're running simple CRUD apps, your workloads are primarily batch processing (consider AWS Batch or Step Functions), or you're a startup that should be shipping features instead of operating infrastructure. Don't use Kubernetes because it's on your resume. Use it because your scale and complexity demand it.
Secrets Management in K8s
Kubernetes Secrets are base64-encoded, not encrypted. Anyone with API access can read them. For production workloads, integrate with an external secret manager: AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager. Use the External Secrets Operator to sync external secrets into K8s automatically. Never commit secrets to Git, even encrypted ones. Use sealed-secrets or external-secrets-operator instead.
K8s Cost Optimization
Kubernetes cost management is a discipline unto itself. Common waste patterns include: over-provisioned resource requests (CPU requested but never used), idle namespaces from abandoned services, persistent volumes attached to stopped pods, and load balancers for internal-only services. Tools like Kubecost, Opencost, and cloud-native cost analyzers can surface these issues. The platform team should provide cost visibility per team and per service, ideally integrated into the developer portal.
Beyond Monitoring: What Observability Actually Means
Monitoring tells you when something is wrong. Observability tells you why. In a distributed system with hundreds of services, you can't predict every failure mode. You need systems that let you ask arbitrary questions about your infrastructure and applications without deploying new instrumentation. That's observability.
The three traditional pillars are metrics (numerical measurements over time), logs (discrete events), and traces (request flows across services). Modern observability adds a fourth pillar: profiling (continuous CPU and memory profiling to understand performance at the code level).
OpenTelemetry: The Standard
OpenTelemetry (OTel) has become the de facto standard for instrumentation. It provides vendor-neutral APIs, SDKs, and tools for generating telemetry data. The key insight: instrument once with OTel, and send data to any backend (Datadog, Grafana, Honeycomb, Jaeger). This decouples your instrumentation from your observability vendor, which is critical for avoiding lock-in.
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
prometheus:
config:
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
processors:
batch:
timeout: 10s
send_batch_size: 1024
memory_limiter:
limit_mib: 512
check_interval: 5s
resource:
attributes:
- key: environment
value: production
action: upsert
exporters:
otlp/grafana:
endpoint: "tempo.monitoring:4317"
tls:
insecure: true
prometheusremotewrite:
endpoint: "http://mimir.monitoring:9090/api/v1/push"
loki:
endpoint: "http://loki.monitoring:3100/loki/api/v1/push"
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, resource]
exporters: [otlp/grafana]
metrics:
receivers: [otlp, prometheus]
processors: [memory_limiter, batch]
exporters: [prometheusremotewrite]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loki]Datadog vs. Grafana Stack
| Dimension | Datadog | Grafana Stack (LGTM) |
|---|---|---|
| Deployment Model | SaaS only | Self-hosted or Grafana Cloud |
| Cost at Scale | Expensive ($23+/host/month base) | Free (self-hosted) or moderate (Cloud) |
| Setup Complexity | Low (agent-based, just works) | High (Loki, Grafana, Tempo, Mimir) |
| Metrics | Excellent (custom metrics, APM) | Prometheus/Mimir (excellent) |
| Logs | Good (expensive at volume) | Loki (cost-effective, label-based) |
| Traces | Excellent (APM built-in) | Tempo (good, growing) |
| Dashboards | Good (built-in) | Excellent (Grafana is the gold standard) |
| Alert Fatigue | Good anomaly detection | Manual threshold tuning |
| Vendor Lock-in | High (proprietary agent, query language) | Low (OSS, standard protocols) |
SLOs, SLIs, and Error Budgets
Service Level Objectives (SLOs) are the foundation of reliability engineering. An SLO is a target for a Service Level Indicator (SLI). For example: "99.9% of HTTP requests should return a non-error response within 200ms." The SLI is the measurement (request success rate). The SLO is the target (99.9%). The error budget is the inverse: if your SLO is 99.9%, your error budget is 0.1% -- that's about 43 minutes of downtime per month.
apiVersion: sloth.slok.dev/v1
kind: PrometheusServiceLevel
metadata:
name: payment-service-availability
spec:
service: payment-service
slos:
- name: requests-availability
objective: 99.9
description: "99.9% of payment requests succeed"
sli:
events:
error_query: sum(rate(http_requests_total{service="payment",code=~"5.."}[{{.window}}]))
total_query: sum(rate(http_requests_total{service="payment"}[{{.window}}]))
alerting:
page_alert:
labels:
severity: critical
team: payments
ticket_alert:
labels:
severity: warningAlerting Best Practices
Alert fatigue is real. If your on-call engineers get 50 alerts a night and ignore most of them, your alerting system is worse than useless -- it's actively harmful because it trains people to ignore alerts. The fix is SLO-based alerting: alert on error budget burn rate, not individual metrics. If your payment service's error budget is burning at 10x the sustainable rate, that's an alert. A single 500 error is not.
Page-worthy (wake someone up): Error budget burning at >14x rate (will exhaust in <2 hours). Revenue-impacting outage. Data loss risk.
Ticket-worthy (next business day): Error budget burning at >3x rate. Gradual performance degradation. Disk filling up.
Dashboard-only (informational): Single instance unhealthy. Temporary latency spike. Pod restart.
Grafana Dashboard Design
A well-designed Grafana dashboard is worth more than a thousand alerts. The problem is that most Grafana dashboards are terrible -- 40 panels crammed onto one page, half of them showing metrics nobody understands, with no clear hierarchy of information. The best dashboards follow the USE method (Utilization, Saturation, Errors) for infrastructure and the RED method (Rate, Errors, Duration) for services.
Structure your dashboards in layers. The top-level dashboard is the service overview: request rate, error rate, latency percentiles (P50, P95, P99), and SLO burn rate. One glance tells you if the service is healthy. Below that, have a detailed dashboard with per-endpoint breakdowns, dependency health, resource utilization, and cache hit rates. Finally, have a debugging dashboard with low-level metrics like GC pauses, thread pool utilization, and connection pool stats. Most on-call engineers should never need to go past the overview dashboard unless something is actually wrong.
The Wall of Graphs: If your dashboard has more than 12 panels, it's trying to do too much. Split it into focused dashboards linked by drill-down.
The Vanity Dashboard: Request count going up doesn't mean anything without context. Always show rates, not totals. Always show percentiles, not averages.
The Stale Dashboard: If nobody looked at a dashboard in 30 days, delete it. Dashboards are not documentation; they're operational tools that require maintenance.
Missing Time Context: Always include deployment markers and annotation overlays on your dashboards. The most common debugging question is "did something change recently?" and a vertical line showing when the last deploy happened answers it instantly.
Log Aggregation at Scale
Logs are the most expensive telemetry signal to store and query. At 100+ services each emitting structured JSON logs, you can easily generate terabytes per day. The platform team must make deliberate decisions about log retention, sampling, and indexing. Loki's approach (index only labels, store log lines in cheap object storage) is dramatically cheaper than Elasticsearch's full-text indexing, but it trades query flexibility for cost. Most organizations find that Loki's label-based queries ({service="payment",level="error"}) cover 90% of debugging use cases.
Establish logging standards across the organization: use structured JSON, include request IDs for correlation, include trace IDs for linking to distributed traces, and use consistent severity levels. The platform team should provide logging libraries that enforce these standards automatically, so individual developers don't need to think about log format.
Distributed Tracing Patterns
In a microservices architecture, a single user request might traverse 10+ services. Without distributed tracing, debugging performance issues or errors across service boundaries is nearly impossible. OpenTelemetry's trace context propagation (W3C Trace Context headers) is the standard. Every service in the request path adds its span to the trace, creating a complete picture of the request's journey through the system.
The platform team should provide auto-instrumentation for common frameworks (Express, Flask, Spring Boot, gRPC) so that developers get tracing out of the box without writing instrumentation code. OpenTelemetry auto-instrumentation libraries make this straightforward for most languages.
The trace above immediately reveals that the Stripe API call is the bottleneck -- 65ms of a 250ms request. Without tracing, you'd be guessing. With it, you know exactly where to optimize. The key implementation detail: make sure your trace sampling strategy is right. Tracing 100% of requests in production is prohibitively expensive. Sample 1-5% of normal traffic, but always trace 100% of error responses and slow requests (those exceeding your P99 target). This ensures you have traces for every interesting request without drowning in data.
Observability-Driven Development
The emerging practice of observability-driven development (ODD) means thinking about observability before writing code, not after. When designing a new feature, ask: "How will I know if this is working correctly in production? What metrics, logs, and traces do I need?" This shifts observability from an afterthought to a design requirement. Platform teams enable ODD by providing easy-to-use instrumentation libraries, standardized dashboards that auto-populate when new services register, and SLO templates that give teams a starting point for defining their reliability targets.
SRE Principles: The Google Way, Distilled
Site Reliability Engineering, as defined by Google, is "what happens when you ask a software engineer to design an operations function." The core principles are: embrace risk (100% reliability is neither possible nor desirable), eliminate toil (manual, repetitive work should be automated), simplicity (simpler systems are more reliable), and error budgets (use the error budget to balance reliability and velocity).
The most important SRE concept for platform leaders is the error budget. It fundamentally changes the reliability conversation from "we must never have outages" to "we have a budget for failures, and we spend it on shipping features." When the error budget is healthy, teams should ship faster. When it's depleted, teams should focus on reliability work. This creates a self-correcting system.
Toil: Measuring and Reducing It
Toil is work that is manual, repetitive, automatable, reactive, and devoid of enduring value. Every SRE team should measure the percentage of time spent on toil. Google's target is no more than 50% toil; the rest should be engineering work that reduces future toil.
Common Sources of Toil in Platform Engineering
- Manually provisioning databases, namespaces, or certificates
- Manually scaling services during traffic spikes
- Manually rotating secrets or certificates
- Responding to alerts that don't require human judgment
- Manually running deployment pipelines or rollbacks
- Copy-pasting configurations between environments
Each of these is a candidate for automation. The platform team's explicit goal should be to systematically eliminate these toil sources through self-service tooling, automation, and better defaults.
On-Call Rotation Design
A well-designed on-call rotation is critical for team sustainability. Poorly designed on-call leads to burnout, attrition, and worse reliability (tired engineers make worse decisions).
Rotation length: 1 week, with handoff during business hours (not Friday at 5pm).
Team size: Minimum 6 people in the rotation so no one is on-call more than every 6 weeks.
Compensation: On-call should be compensated. Period. Whether it's extra PTO, cash, or comp time, people who carry pagers deserve recognition.
Escalation: Primary on-call has 15 minutes to acknowledge. If unacknowledged, escalate to secondary. If secondary doesn't respond in 15 minutes, escalate to engineering manager.
Follow-the-sun: For global teams, route pages to the on-call in the awake timezone. Nobody should be woken up at 3am if there's a teammate in a timezone where it's 11am.
Incident Management Framework
When things go wrong (and they will), having a structured incident management process is the difference between a 30-minute resolution and a 6-hour fire drill. The Incident Commander (IC) model, popularized by PagerDuty and Blameless, assigns clear roles during an incident.
Incident Severity Classification
| Severity | Impact | Response Time | Communication | Example |
|---|---|---|---|---|
| SEV-1 (Critical) | Complete outage or data loss | 15 minutes | Executive notification, status page, customer comms | Payment processing down |
| SEV-2 (Major) | Significant degradation | 30 minutes | Engineering leadership, status page | 50% error rate on checkout |
| SEV-3 (Minor) | Partial degradation | 4 hours | Team channel notification | Slow dashboard loading |
| SEV-4 (Low) | Cosmetic or minor issue | Next business day | Ticket created | Typo in error message |
Blameless Postmortems
The postmortem is the most important artifact of any incident. Done well, it prevents recurrence and builds organizational knowledge. Done poorly (or not at all), the same incidents repeat endlessly.
A blameless postmortem focuses on systems and processes, not people. "The deployment went out without adequate testing" is blameless. "John deployed without testing" is not. The distinction matters because blame discourages honesty, and dishonest postmortems don't prevent recurrences.
Postmortem Template
- Title and date: Descriptive title, incident date, and severity
- Summary: 2-3 sentence description of what happened and the impact
- Timeline: Minute-by-minute account from detection to resolution
- Root cause: Technical root cause (often multiple contributing factors)
- Impact: Quantified impact (users affected, revenue lost, duration)
- What went well: Things that worked during the response
- What went poorly: Things that slowed down the response
- Action items: Specific, assigned, time-bound tasks to prevent recurrence. Each action item must have an owner and a due date.
- Lessons learned: Broader takeaways for the organization
The most common failure mode for postmortems is generating action items that never get completed. If your postmortem action items aren't tracked in the same system as your regular work (Jira, Linear, etc.) and aren't reviewed weekly, they will rot. Assign owners, set deadlines, and review completion in your regular team standup. An incomplete action item from a postmortem is a ticking time bomb.
SRE Team Structures
There are three common models for organizing SRE teams, and each has tradeoffs. The centralized model has a single SRE team that supports all product teams. This works well at smaller organizations (under 200 engineers) because it concentrates expertise and creates consistent practices. The downside: the SRE team becomes a bottleneck, and they lack deep context on any individual product. The embedded model places SREs directly on product teams. They build deep domain knowledge and have strong relationships with developers. The downside: practices diverge across teams, and SREs can become glorified ops people who just respond to pages. The hybrid model (used by Google, Netflix, and most large platform organizations) has a central SRE platform team that builds shared tooling and defines standards, plus embedded SREs on critical product teams who apply those standards with domain-specific knowledge.
The shift from threshold-based alerting ("alert if CPU > 90%") to SLO-based alerting ("alert if error budget burn rate exceeds 14x") is the single most impactful change you can make to your alerting system. With SLO-based alerting, you only get paged when users are actually affected at a rate that threatens your reliability targets. A brief CPU spike that doesn't impact error rates or latency? No alert. A subtle data corruption bug that causes 0.5% of requests to return wrong results? Alert, because it's silently draining your error budget. Tools like Sloth, Google's SLO burn-rate alerting rules, and Nobl9 implement this pattern.
Chaos Engineering
Chaos engineering is the practice of deliberately injecting failures into your system to verify that your resilience mechanisms work. Netflix pioneered this with Chaos Monkey (randomly killing production instances), but the field has matured significantly. Modern chaos engineering is hypothesis-driven: "We believe that if we kill 2 of 3 payment service replicas, the remaining replica will handle traffic without errors." You define the steady state, introduce the perturbation, and measure whether the steady state was maintained.
Tools like Litmus (Kubernetes-native), Gremlin (commercial), and AWS Fault Injection Simulator make it straightforward to run chaos experiments. Start with non-production environments, graduate to production during low-traffic windows, and eventually run continuous chaos in production. The most common first experiment: kill a random pod and verify that the service recovers within 30 seconds without user impact. If that fails, you've found a real problem that would have eventually caused an outage.
Runbooks That Actually Get Used
A runbook is a documented procedure for handling a specific operational scenario. Most runbooks are written once and never read because they're outdated, hard to find, or too vague to be useful. Effective runbooks are: linked directly from alerts (every PagerDuty alert should have a runbook URL), tested regularly (during game days or chaos engineering experiments), maintained by the on-call rotation (the person who used the runbook updates it), and structured for a stressed human at 3am (numbered steps, copy-pasteable commands, clear decision trees).
Integrate runbooks into your IDP. When a developer clicks on an alert in the developer portal, the relevant runbook should be one click away. Tools like Backstage TechDocs, Notion, or Confluence can host runbooks, but the key is discoverability and freshness.
# Runbook: Payment Service High Error Rate
## Alert: payment-service-error-budget-burn
### 1. Verify the alert
# Check current error rate in Grafana:
open https://grafana.internal/d/payment-overview
### 2. Check recent deployments
kubectl -n payments rollout history deployment/payment-service
# If a deployment happened in the last 30 minutes, consider rollback:
kubectl -n payments rollout undo deployment/payment-service
### 3. Check dependency health
# Stripe API status:
curl -s https://status.stripe.com/api/v2/status.json | jq .status
# Database connection pool:
kubectl -n payments exec deploy/payment-service -- \
curl -s localhost:8080/debug/connpool | jq .
### 4. Check pod health
kubectl -n payments get pods -l app=payment-service
kubectl -n payments top pods -l app=payment-service
### 5. Escalation
# If unresolved after 15 minutes, escalate to #incident-payments
# Page the payments team tech lead via PagerDutyWhy Build Systems Matter for Platform Engineering
The build system is the foundation of the developer inner loop: edit, build, test, repeat. Every second added to the build-test cycle is multiplied by every developer, every iteration, every day. At scale, build performance isn't a nice-to-have -- it's a competitive advantage. Google, Meta, and other large tech companies invest heavily in build infrastructure because they understand that developer productivity compounds.
Bazel Deep Dive
Bazel, originally developed at Google (where it's known as Blaze), is the most powerful build system available for large codebases. It's designed for correctness, reproducibility, and speed. Bazel's key features: hermetic builds (builds depend only on declared inputs, ensuring reproducibility), content-based caching (only rebuild what changed, based on file contents not timestamps), remote execution (distribute builds across a cluster of machines), and multi-language support (Java, Go, Python, C++, TypeScript, and more through rules).
When to Use Bazel
Bazel makes sense when your codebase exceeds what language-specific tools can handle efficiently. If your Go monorepo takes 20 minutes to test, if your Java project has 500+ modules, or if you have a polyglot monorepo with shared protobuf definitions, Bazel is likely the right choice. For smaller codebases, the learning curve and configuration overhead may not be justified.
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_push")
go_library(
name = "payment_lib",
srcs = glob(["*.go"]),
importpath = "github.com/myorg/platform/services/payment",
visibility = ["//visibility:private"],
deps = [
"//proto/payment:payment_go_proto",
"//lib/observability",
"//lib/database",
"@org_golang_google_grpc//:go_default_library",
],
)
go_binary(
name = "payment",
embed = [":payment_lib"],
visibility = ["//visibility:public"],
)
go_test(
name = "payment_test",
srcs = glob(["*_test.go"]),
embed = [":payment_lib"],
deps = ["@com_github_stretchr_testify//assert"],
)
oci_image(
name = "image",
base = "@distroless_base",
entrypoint = ["/payment"],
tars = [":payment_layer"],
)Remote Caching and Remote Execution
Remote caching is the single most impactful Bazel feature for developer productivity. When developer A builds a target, the build outputs are stored in a shared cache (typically backed by a service like Buildbarn, BuildBuddy, or Google's Remote Build Execution). When developer B builds the same target with the same inputs, the outputs are fetched from cache instead of rebuilding. This can reduce build times from minutes to seconds.
# Remote caching with BuildBuddy
build --remote_cache=grpcs://remote.buildbuddy.io
build --remote_header=x-buildbuddy-api-key=YOUR_API_KEY
build --remote_upload_local_results=true
build --remote_download_toplevel
# Remote execution (for CI)
build:ci --remote_executor=grpcs://remote.buildbuddy.io
build:ci --jobs=100
# Performance optimizations
build --experimental_remote_merkle_tree_cache
build --experimental_remote_cache_compressionTurborepo for JavaScript/TypeScript Monorepos
If your organization runs a JavaScript or TypeScript monorepo, Turborepo (acquired by Vercel) is the standard choice. It provides task scheduling with dependency awareness, local and remote caching, and parallel execution. It's significantly simpler than Bazel for JS-only codebases.
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**"],
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
},
"lint": {
"outputs": []
},
"dev": {
"cache": false,
"persistent": true
}
}
}Monorepo vs. Polyrepo
| Factor | Monorepo | Polyrepo |
|---|---|---|
| Code Sharing | Trivial (just import) | Requires publishing packages |
| Atomic Changes | Easy (one PR changes everything) | Requires coordinated releases |
| CI Complexity | High (need affected-target detection) | Low (each repo has its own CI) |
| IDE Performance | Can degrade at scale | Always fast per-repo |
| Ownership | Requires CODEOWNERS files | Clear by repository |
| Onboarding | Clone once, everything is there | Need to find and clone many repos |
Developer Productivity Metrics
You can't improve what you don't measure. The DORA metrics (Deployment Frequency, Lead Time for Changes, Change Failure Rate, Mean Time to Recovery) measure software delivery performance at the team level. The SPACE framework (Satisfaction, Performance, Activity, Communication, Efficiency) provides a more holistic view of developer productivity that includes subjective experience.
The developer inner loop (edit, build, test, see result) should take under 10 seconds for incremental changes. If it takes longer, developers lose flow state, context-switch to Slack, and productivity drops dramatically. Measure your inner loop time and treat it as a critical SLI. Tools like devcontainers, hot-reload frameworks, and local build caching directly improve this metric.
Developer Environment Standardization
Reproducible development environments eliminate the "works on my machine" problem. Dev Containers (VS Code) define development environments as Docker containers with all dependencies pre-installed. GitHub Codespaces provide cloud-hosted development environments that spin up in seconds. Gitpod offers a similar experience with more flexibility on hosting. The platform team should provide pre-configured dev environments for every supported stack, so a new developer can go from git clone to running tests in under 5 minutes.
{
"name": "Payment Service Dev",
"image": "ghcr.io/myorg/devcontainer-python:3.12",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/kubectl-helm-minikube:1": {}
},
"forwardPorts": [8080, 5432, 6379],
"postCreateCommand": "make setup",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker"
],
"settings": {
"python.defaultInterpreterPath": "/usr/local/bin/python",
"python.testing.pytestEnabled": true
}
}
}
}The critical insight about dev environments: the platform team owns the base images. Individual teams customize on top with service-specific dependencies, but the platform team maintains the language-runtime base images with approved compiler versions, security patches, and standard tooling pre-installed. This creates a layer of consistency while preserving team autonomy. At Shopify, their dev container images are rebuilt nightly with the latest security patches and pushed to an internal registry. When a developer opens a codespace, they automatically get the latest secure environment.
Reducing Inner Loop Time: A Practical Guide
The developer inner loop (edit, build, test, see result) is where developers spend most of their time. Every optimization here has outsized impact. The key strategies:
- Hot reload: For interpreted languages (Python, Node.js, Ruby), use file watchers that automatically restart the server on file changes. Tools like nodemon, uvicorn --reload, and guard eliminate the manual restart step entirely.
- Incremental compilation: For compiled languages (Go, Rust, Java), use incremental build tools. Go's build cache is excellent by default. For Java, Gradle's incremental compilation is much faster than Maven. For Rust, use
cargo watchwith incremental compilation enabled. - Local test filtering: Run only the tests affected by your change.
pytest -k "test_payment"runs only matching tests.jest --onlyChangedruns tests for changed files.go test ./services/payment/...runs tests only in the changed package. - Mock external dependencies: Calls to external APIs, databases, and message queues should be mockable locally. Docker Compose files that spin up local Postgres, Redis, and localstack (mock AWS) in seconds are table stakes for a modern platform.
- Preview environments: For frontend changes, deploy preview environments on every PR (Vercel, Netlify, or custom K8s-based preview envs). This eliminates the need to run the full stack locally for UI changes.
Don't rely solely on DORA metrics. They measure team-level delivery performance but miss individual developer experience. Complement DORA with developer surveys (quarterly, anonymous, measuring satisfaction and friction points), inner loop timing (how long from saving a file to seeing the result), CI wait time (time from pushing a commit to seeing green/red), and environment setup time (time from cloning a repo to running the first test). These subjective and objective measures together paint a complete picture of developer productivity.
Why Feature Flags Are a Platform Capability
Feature flags decouple deployment from release. You deploy code to production continuously, but you control who sees new features through flags. This is transformative because it means deployment becomes routine (just merging code) and release becomes a business decision (flipping a flag). For platform teams, feature flags are infrastructure that enables progressive delivery, experimentation, and safe rollouts.
Without feature flags, your deployment pipeline is your release process. Every deployment is a release. Every release is all-or-nothing. With feature flags, you can deploy ten times a day and gradually roll out features to 1%, then 5%, then 25%, then 100% of users, monitoring metrics at each stage.
Feature Flag Architecture
LaunchDarkly vs. Unleash vs. Flagsmith
| Feature | LaunchDarkly | Unleash | Flagsmith |
|---|---|---|---|
| Hosting | SaaS only | Self-hosted or Cloud | Self-hosted or Cloud |
| Cost | Expensive ($10/seat/mo+) | Free (OSS) or paid | Free (OSS) or paid |
| SDK Quality | Excellent (15+ languages) | Good (10+ languages) | Good (10+ languages) |
| Targeting Rules | Excellent (segments, rules, %) | Good (strategies) | Good (segments) |
| Experimentation | Built-in A/B testing | Basic | Basic |
| Audit Trail | Excellent | Good | Good |
| Evaluation Speed | ~1ms (local eval) | ~1ms (local eval) | ~5ms (API-based) |
| Best For | Enterprise, experimentation | Self-hosted, privacy-focused | Startups, simplicity |
Flag Implementation Pattern
from openfeature import api
from openfeature.contrib.provider.flagd import FlagdProvider
# Initialize the OpenFeature SDK with flagd provider
api.set_provider(FlagdProvider(
host="flagd.platform.svc.cluster.local",
port=8013,
tls=True,
))
client = api.get_client()
def checkout(user, cart):
# Evaluation context for targeting rules
context = {
"user_id": user.id,
"email": user.email,
"country": user.country,
"plan": user.subscription_plan,
"beta_tester": user.is_beta,
}
# Boolean flag: is the new checkout flow enabled?
if client.get_boolean_value(
"new-checkout-flow",
default_value=False,
evaluation_context=context,
):
return new_checkout_flow(user, cart)
else:
return legacy_checkout_flow(user, cart)
# String flag: which payment provider variant?
provider = client.get_string_value(
"payment-provider-variant",
default_value="stripe-v2",
evaluation_context=context,
)
return process_payment(provider, cart)Types of Feature Flags
Not all flags are created equal. Understanding the different types helps you manage them properly:
- Release Flags: Enable/disable new features during rollout. Short-lived (days to weeks). Should be removed after full rollout. Example:
new-checkout-flow. - Operational Flags: Control system behavior in production. Long-lived. Used for graceful degradation. Example:
enable-recommendation-engine(turn off during high load). - Experiment Flags: A/B testing variants. Medium-lived (weeks to months). Tied to analytics. Example:
pricing-page-variant(A/B/C test different pricing layouts). - Permission Flags: Gate features by user segment. Long-lived. Example:
premium-feature-dashboard(only for paid users).
Feature flags that outlive their purpose become technical debt. Every flag adds a code path that must be tested, understood, and maintained. A codebase with 500 stale flags is a minefield of unexpected behavior. Implement a flag lifecycle policy: every release flag must have an expiry date. After 30 days, if the flag is still in code, an automated PR is created to remove it. Track flag age on a dashboard. Treat flags older than 90 days as bugs.
Progressive Delivery with Feature Flags
Progressive delivery combines feature flags with automated analysis to create self-healing releases. The pattern: deploy new code with a flag defaulting to off. Enable the flag for 1% of traffic. Automatically compare error rates, latency, and business metrics between the flag-on and flag-off populations. If metrics are healthy, automatically increase to 5%, 25%, 50%, 100%. If metrics degrade, automatically roll back the flag to 0%.
This is the holy grail of continuous delivery: fully automated, metrics-driven releases that require zero human intervention for successful deployments and automatically protect users from bad deployments. Tools like Flagger (for Kubernetes), LaunchDarkly (with metric integration), and custom solutions can implement this pattern.
The implementation detail that matters: statistical significance. You can't compare a 1% sample to the 99% control group and draw meaningful conclusions about error rate differences. You need enough traffic at each stage to achieve statistical significance before promoting. For a service handling 10,000 requests per minute, 1% gives you 100 requests per minute -- enough to detect a 5% error rate increase with confidence within 10-15 minutes. For lower-traffic services, you may need to keep the canary running for hours before promoting. Build this math into your progressive delivery automation.
Trunk-Based Development with Flags
Feature flags are the enabling mechanism for trunk-based development at scale. Without flags, long-lived feature branches are the only way to hide incomplete work. With flags, developers merge to main every day (or multiple times per day), and incomplete features are simply gated behind flags that default to off. This eliminates merge conflicts, reduces integration risk, and keeps the main branch always deployable.
The workflow is straightforward: developer creates a feature flag for their new capability, wraps new code paths in flag checks, merges small increments to main daily, and when the feature is complete and tested, gradually rolls out via the flag dashboard. The feature branch never existed -- all work happened on main, protected by the flag. This is how Netflix, Google, and most high-performing engineering organizations operate, and it's a night-and-day improvement over gitflow for teams that adopt it.
Percentage Rollouts and Canary Patterns
The power of percentage rollouts is that they let you validate changes with real traffic before committing to a full release. The key technical detail: percentage rollouts must be sticky. If user Alice falls into the 5% cohort, she should consistently see the new experience on every request, not randomly flip between old and new. This is typically implemented by hashing the user ID with the flag name and using the hash to determine the cohort. This ensures consistency without requiring server-side session state.
import hashlib
def is_in_rollout(user_id: str, flag_name: str, percentage: int) -> bool:
"""Sticky percentage rollout using consistent hashing."""
hash_input = f"{flag_name}:{user_id}".encode()
hash_value = int(hashlib.sha256(hash_input).hexdigest(), 16)
bucket = hash_value % 100 # 0-99
return bucket < percentage
# User "alice" is consistently in or out for each flag
is_in_rollout("alice", "new-checkout", 5) # Always True or always False
is_in_rollout("alice", "new-checkout", 50) # Same answer every timeKill Switches for Incidents
Operational flags serve as kill switches during incidents. When your recommendation service is overloaded and dragging down the checkout page, a single flag toggle can disable recommendations without a code deployment. This is faster, safer, and more reversible than any code change. Every external dependency should have a kill switch. Your platform should make it trivial to add one.
def get_recommendations(user_id: str) -> list:
# Kill switch: if recommendation service is struggling,
# return cached/default recommendations instead
if not flags.is_enabled("enable-live-recommendations"):
return get_cached_recommendations(user_id)
try:
return recommendation_client.get(
user_id,
timeout_ms=200, # aggressive timeout
)
except (TimeoutError, ServiceUnavailableError):
metrics.increment("recommendations.fallback")
return get_cached_recommendations(user_id)Why Database Migrations Are the Hardest Problem
Every other topic in this course has a reasonable "undo" button. Bad deployment? Roll back. Bad config? Revert. Bad feature flag? Toggle it off. But database migrations? If you've dropped a column, that data is gone. If you've locked a table with 500 million rows, your application is down until the migration completes. Database migrations at scale are the highest-stakes operation in software engineering, and they deserve the deepest respect.
The fundamental tension is this: your database schema needs to evolve (new features require new columns, tables, indexes), but your database is a shared, stateful resource that serves live traffic. Any schema change that takes locks, rewrites data, or changes constraints can potentially bring down your application. The techniques in this module exist to resolve that tension.
The Expand-and-Contract Pattern
The expand-and-contract pattern (also called "parallel change") is the gold standard for zero-downtime schema migrations. Instead of making a breaking change in one step, you break it into three safe steps:
- Expand: Add the new column/table alongside the old one. Both coexist. Application writes to both.
- Migrate: Backfill existing data from old to new. Application reads from new, writes to both.
- Contract: Remove the old column/table once all code paths use the new one. This is the only step that removes something, and by this point, nothing depends on it.
MySQL: pt-online-schema-change and gh-ost
MySQL's ALTER TABLE can lock the table for the duration of the change on older versions and certain operations. For tables with millions of rows, this can mean minutes or hours of downtime. Two tools solve this problem:
pt-online-schema-change (Percona Toolkit) creates a new table with the desired schema, copies data in chunks, uses triggers to capture changes made during the copy, and then swaps the tables atomically. It's battle-tested and widely used, but the trigger-based approach adds overhead to write operations during the migration.
gh-ost (GitHub Online Schema Tool) takes a different approach: instead of triggers, it reads the binary log to capture changes. This is less intrusive because it doesn't modify the source table's write path. gh-ost also supports throttling, cut-over control, and testing migrations without actually executing them.
# Add an index to a 500M-row table with zero downtime
gh-ost \
--host="primary.db.internal" \
--database="payments" \
--table="transactions" \
--alter="ADD INDEX idx_created_status (created_at, status)" \
--chunk-size=1000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--throttle-control-replicas="replica1.db.internal,replica2.db.internal" \
--max-lag-millis=1500 \
--cut-over=default \
--execute
# Key flags explained:
# --chunk-size: rows copied per iteration (tune for your write load)
# --max-load: pause migration if MySQL threads exceed threshold
# --critical-load: abort migration if threads exceed this
# --max-lag-millis: pause if replica lag exceeds this
# --cut-over: how to swap tables (default = atomic rename)PostgreSQL: pg_repack and Logical Replication
PostgreSQL handles many ALTER TABLE operations without exclusive locks (adding a column with no default, adding an index with CREATE INDEX CONCURRENTLY). However, some operations still require a full table rewrite: changing a column type, adding a NOT NULL constraint with a default to an existing column (pre-PG11), or doing a full table VACUUM.
pg_repack reorganizes tables online without exclusive locks. It's useful for reclaiming space (after deleting many rows) and for schema changes that would otherwise lock the table.
-- SAFE: Add column without default (no rewrite)
ALTER TABLE orders ADD COLUMN shipping_method VARCHAR(50);
-- SAFE: Add index concurrently (no lock)
CREATE INDEX CONCURRENTLY idx_orders_status
ON orders (status, created_at);
-- SAFE in PG11+: Add column with default (no rewrite)
ALTER TABLE orders
ADD COLUMN priority INTEGER DEFAULT 0 NOT NULL;
-- DANGEROUS: Changing column type (requires rewrite + lock)
-- DON'T: ALTER TABLE orders ALTER COLUMN amount TYPE NUMERIC(12,2);
-- DO: Use expand-and-contract pattern instead:
ALTER TABLE orders ADD COLUMN amount_v2 NUMERIC(12,2);
-- Then backfill, switch reads, drop old column
-- DANGEROUS: Adding NOT NULL to existing column
-- DON'T: ALTER TABLE orders ALTER COLUMN shipping_method SET NOT NULL;
-- DO: Add a CHECK constraint first (validated in background)
ALTER TABLE orders
ADD CONSTRAINT chk_shipping_not_null
CHECK (shipping_method IS NOT NULL) NOT VALID;
ALTER TABLE orders
VALIDATE CONSTRAINT chk_shipping_not_null;Migration Tools: Flyway vs. Liquibase
| Feature | Flyway | Liquibase |
|---|---|---|
| Migration Format | SQL files (simple, explicit) | XML/YAML/JSON/SQL (abstract) |
| Rollback | Manual (write your own undo SQL) | Auto-generated (for some changes) |
| Diff Support | No | Yes (compare schemas) |
| Learning Curve | Low (just write SQL) | Medium (DSL to learn) |
| Best For | Teams that want control | Teams that want abstraction |
Rails and Django Migration Pitfalls
Framework-level migration tools (Rails ActiveRecord migrations, Django migrations) are convenient but dangerous at scale. They hide the SQL being generated, making it easy to accidentally create a migration that locks a table for minutes.
The 20-minute lock: A Rails developer ran add_column :orders, :status, :string, default: 'pending' on a 200M-row table. In Rails/MySQL, this rewrites the entire table while holding an exclusive lock. The orders table was locked for 20 minutes. No orders could be placed. Revenue impact: $800K.
The fix: Use strong_migrations gem (Rails) or django-safemigrate (Django) to catch dangerous migration patterns during development. These gems analyze your migrations and block operations that would take locks on large tables. They're the cheapest investment you can make in database safety.
# Gemfile
gem 'strong_migrations'
# config/initializers/strong_migrations.rb
StrongMigrations.start_after = 20240101000000
# These operations will be blocked:
# - Adding a column with a default value (pre-Rails 7/PG11)
# - Adding an index non-concurrently
# - Changing a column type
# - Removing a column that's still referenced
# - Adding a NOT NULL constraint without a defaultData Backfills at Scale
After adding a new column (the "expand" phase), you need to backfill existing rows. For a 500M-row table, this is itself a significant operation. Key principles for safe backfills:
- Batch processing: Update rows in batches of 1,000-10,000, not all at once. A single UPDATE of 500M rows will lock the entire table and overwhelm your transaction log.
- Throttling: Add sleep between batches to avoid overwhelming the database. Monitor replication lag and pause if it exceeds thresholds.
- Idempotency: Backfill scripts must be safe to run multiple times. Use
WHERE new_column IS NULLto skip already-backfilled rows. - Progress tracking: Log progress so you can resume if the backfill is interrupted. Store the last processed ID.
import time
import logging
logger = logging.getLogger("backfill")
BATCH_SIZE = 5000
SLEEP_SECONDS = 0.5
def backfill_full_name(conn):
"""Backfill full_name from first_name + last_name."""
cursor = conn.cursor()
total_updated = 0
while True:
cursor.execute("""
UPDATE users
SET full_name = first_name || ' ' || last_name
WHERE id IN (
SELECT id FROM users
WHERE full_name IS NULL
ORDER BY id
LIMIT %s
)
RETURNING id
""", (BATCH_SIZE,))
updated = cursor.rowcount
conn.commit()
total_updated += updated
logger.info(f"Backfilled {total_updated} rows")
if updated < BATCH_SIZE:
logger.info("Backfill complete!")
break
# Check replica lag before continuing
lag = get_replica_lag(conn)
if lag > 2.0: # seconds
logger.warning(f"Replica lag {lag}s, pausing...")
while get_replica_lag(conn) > 1.0:
time.sleep(5)
time.sleep(SLEEP_SECONDS)Multi-Region Migration Coordination
If your database is replicated across regions, schema migrations become even more complex. You need to ensure that the migration is applied to the primary first, then replicated to all secondaries before deploying application code that depends on the new schema. In a blue-green database setup, the migration and application deployment must be carefully choreographed to avoid windows where the application expects a schema that doesn't exist yet on some replicas.
Before running any migration in production:
1. Run the migration against a production-sized copy of the database. Measure lock time and duration.
2. Check for exclusive locks. If the migration takes any lock for more than 1 second, use an online schema change tool.
3. Have a rollback plan. For expand operations, the rollback is dropping the new column. For contract operations, there is no rollback -- make sure you're ready.
4. Schedule migrations during low-traffic periods. Even zero-downtime tools create some additional load.
5. Monitor replication lag during and after the migration. Pause if lag exceeds your SLO threshold.
6. Communicate with the team. Even zero-downtime migrations can cause unexpected behavior if application code isn't aware of the schema change.
Lessons from the Trenches
Database migrations are where theory meets the unforgiving reality of production data. The companies that do this well share common traits: they have a migration review process (every migration PR is reviewed by a DBA or platform engineer), they use automated safety checks (strong_migrations, squawk for Postgres), they practice migrations on staging databases with production-scale data, and they treat migration failures as incidents with full postmortems. The platform team's role is to make safe migrations easy and dangerous migrations impossible. Provide tooling, guardrails, documentation, and expert review. The cost of getting this wrong is too high to leave it to chance.
Need this for a date?
Turn this course into a ramp-up pack sized to your minutes per day, or build an interview or certification pack for the day you need it.