Education › Data › Stage 4: Operate

Data platform infrastructure

IaC for data, environments, CI/CD for pipelines and models, cost controls and platform boundaries.

Advanced ~35 min read Module 15 of 16

Everything in this track runs on infrastructure somebody has to provision, secure, pay for and change without breaking the nightly run: buckets and warehouses, an orchestrator, compute for Spark and dbt, secrets, networking, identities. Treated as click-ops, a data platform becomes a snowflake nobody can rebuild and a bill nobody can explain. Treated as software — infrastructure in Terraform, pipelines and models deployed through CI, environments that mirror each other, cost attributed to owners — it becomes something a small team can run for a large organisation. This module applies the DevOps track to data: the platform components and their boundaries, IaC for data resources, CI/CD for pipelines and dbt, environments and promotion, and cost control.

After this module you can
  • Describe the components of a data platform and the boundaries between them
  • Provision data infrastructure with Terraform: storage, warehouse objects, orchestrator, identities, secrets
  • Build CI/CD for pipelines and dbt projects with environment promotion
  • Design development, staging and production environments for data without copying production data everywhere
  • Attribute and control cost across storage, compute and orchestration

The components and their boundaries

A data platform has a small number of moving parts, and most operational pain comes from unclear boundaries between them. Ingestion (connectors, CDC, API pullers) lands raw data. Storage (object storage with a table format, or the warehouse's own storage) holds every layer. Compute runs transformations: the warehouse engine for SQL, Spark or single-node engines for code. Orchestration schedules and records runs. Transformation code (dbt, pipelines) lives in repositories. Governance and observability (catalog, quality, lineage, metrics) sit across all of it. Serving (BI, reverse ETL, feature stores, APIs) reads the outputs. Each is a separate concern with its own identity, its own cost line and its own deploy path.

ComponentManaged optionsWhat you still own
Storage and tablesS3/GCS/Blob + Iceberg catalog; warehouse-nativeLayout, retention, access policies
SQL computeBigQuery, Snowflake, Redshift, Databricks SQLSizing, isolation, cost caps
Code computeEMR, Dataproc, Databricks, Glue; containers on KubernetesJob packaging, dependencies, scaling limits
OrchestrationManaged Airflow (MWAA, Composer, Astronomer), Dagster Cloud, PrefectDAG code, alerting, secrets, upgrades
IngestionFivetran-style connectors, managed CDC, Kafka ConnectContracts, monitoring, cost per row
Note

Prefer managed services for the stateful, hard-to-operate parts (warehouse, Kafka, orchestrator control plane) and own the parts that encode your business (models, pipelines, contracts, policies). Operating Kafka yourself is a job; writing good models is your job.

Infrastructure as code for data

Every resource that outlives a run belongs in Terraform (or the equivalent): buckets with lifecycle rules, warehouse databases, schemas, roles and grants, orchestrator environments, service identities, secrets, network paths, budgets. The reasons are the DevOps track's: reproducibility, review, drift detection, and one place where an auditor can read how access is granted. Warehouse objects are the part teams forget; Terraform providers exist for the major warehouses, and putting roles and grants there means access review is a diff, exactly as the governance module wanted.

A slice of a data platform in Terraform: a lake bucket with lifecycle and encryption, a warehouse schema with role-based grants, and the identity a pipeline runs as.
hcl
resource "aws_s3_bucket" "lake" {
  bucket = "acme-lake-${var.env}"
}

resource "aws_s3_bucket_lifecycle_configuration" "lake" {
  bucket = aws_s3_bucket.lake.id
  rule {
    id     = "raw-tiering"
    status = "Enabled"
    filter { prefix = "raw/" }
    transition { days = 90;  storage_class = "STANDARD_IA" }
    transition { days = 365; storage_class = "GLACIER_IR" }
    expiration { days = 730 }
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "lake" {
  bucket = aws_s3_bucket.lake.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } }
}

# warehouse objects via the Snowflake provider: schema, role, and grants reviewed as code
resource "snowflake_schema" "marts" {
  database = var.warehouse_db
  name     = "MARTS"
}

resource "snowflake_account_role" "analyst" { name = "ANALYST" }

resource "snowflake_grant_privileges_to_account_role" "analyst_marts" {
  account_role_name = snowflake_account_role.analyst.name
  privileges        = ["SELECT"]
  on_schema_object {
    future {
      object_type_plural = "TABLES"
      in_schema          = "\"${var.warehouse_db}\".\"${snowflake_schema.marts.name}\""
    }
  }
}

# the identity the orders pipeline runs as: reads raw, writes bronze/silver, nothing else
resource "aws_iam_role" "orders_pipeline" {
  name               = "orders-pipeline-${var.env}"
  assume_role_policy = data.aws_iam_policy_document.orchestrator_assume.json
}

Parameterise by environment (var.env) so the same code produces development, staging and production with different names and sizes. Keep secrets out of state and code: Terraform creates the secret container, a pipeline or a human writes the value, and the workload reads it by identity. Run policy checks on the plan (public buckets, wildcard grants, missing encryption) in CI, as the cloud security module described.

CI/CD for pipelines and models

Pipeline code and dbt projects are software and deploy like it. A pull request runs unit tests on transformation functions (small fixtures, fast), lints SQL and Python, validates DAG files parse, runs the dbt slim build against a CI schema, and runs the governance and contract checks. Merge to main deploys to staging: the orchestrator picks up the new DAGs (from a synced bucket, a container image or a git sync), dbt artifacts are published, and a smoke run executes on a sample. Promotion to production is a tag or a manual approval, and production runs only what is deployed — nobody edits a DAG on the server.

A data repository's CI: tests and lint on every PR, slim dbt build against a CI target, DAG parse check, then deploy on merge.
yaml
name: data-platform

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: {python-version: "3.12", cache: pip}
      - run: pip install -r requirements-dev.txt
      - run: ruff check . && sqlfluff lint models/ --dialect snowflake
      - run: pytest tests/unit -q
      - name: DAGs must parse
        run: python -c "from airflow.models import DagBag; b = DagBag('dags', include_examples=False); assert not b.import_errors, b.import_errors"
      - name: dbt slim build
        env:
          DBT_TARGET: ci
        run: |
          dbt deps
          dbt build --select state:modified+ --state ./prod-artifacts --target ci --fail-fast
      - run: python scripts/check_governance_tags.py

  deploy_staging:
    if: github.ref == 'refs/heads/main'
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with: {role-to-assume: "${{ vars.DEPLOY_ROLE }}", aws-region: eu-west-1}
      - run: aws s3 sync dags/ "s3://acme-airflow-staging/dags/" --delete
      - run: dbt docs generate --target staging && aws s3 cp target/manifest.json "s3://acme-dbt-artifacts/staging/manifest.json"

  deploy_prod:
    needs: deploy_staging
    runs-on: ubuntu-latest
    environment: production            # requires an approval in GitHub before this job runs
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with: {role-to-assume: "${{ vars.DEPLOY_ROLE_PROD }}", aws-region: eu-west-1}
      - run: aws s3 sync dags/ "s3://acme-airflow-prod/dags/" --delete

Keep the transformation code, the DAGs, the Terraform and the governance tags in one repository per domain, or a small number of them, so a change to a model and to its schedule and to its grants is one reviewed pull request. Version everything, pin dependencies, and store run artifacts (dbt manifests, test results) after production runs for slim CI and observability.

Environments for data

Software environments are cheap copies; data environments are not, because the data is the expensive part and copying production data everywhere is a privacy problem. The workable pattern: development is a per-developer schema in the warehouse built from a small, masked sample of production (or synthetic data), fast to rebuild, with dbt targets pointing at it. Staging mirrors production structure and runs the full pipeline on a recent, masked subset or on production data with restricted access, to catch scale and integration problems. Production runs only deployed code, on its own identities and compute, with the SLAs from the observability module. Zero-copy clones and table-format snapshots make staging datasets cheap where the platform supports them.

  • Same code, different targets: dbt profiles, orchestrator variables and Terraform var.env select the environment; no if env == 'prod' branches in logic.
  • Masked samples for development, generated by a pipeline that applies the governance module's masking rules; refreshed weekly.
  • Staging is where backfills and migrations are rehearsed before production.
  • Separate cloud accounts or projects per environment, so a staging mistake cannot touch production data or identities.
  • Promotion is a deploy, never a manual copy of files or a hand-run notebook.

Cost: attribute, cap, review

Data platforms have three big cost lines: warehouse compute, storage (and the API calls on it), and code compute (Spark clusters, orchestrator workers). The warehouse module covered compute tuning; the platform's job is attribution and control. Tag every resource and query with team and pipeline. Give each domain its own warehouse or reservation so the bill has an owner. Set budgets with alerts at 50, 80 and 100 percent, byte caps on ad hoc access, auto-suspend everywhere, and lifecycle rules on storage. Review the top cost drivers weekly with the owners, and treat an unexplained jump as an incident.

A budget with alerts, tagged so the owner is paged rather than finance discovering it at month end.
hcl
resource "aws_budgets_budget" "data_platform" {
  name         = "data-platform-${var.env}"
  budget_type  = "COST"
  limit_amount = "12000"
  limit_unit   = "USD"
  time_unit    = "MONTHLY"

  cost_filter {
    name   = "TagKeyValue"
    values = ["user:platform$data"]
  }

  dynamic "notification" {
    for_each = [50, 80, 100]
    content {
      comparison_operator        = "GREATER_THAN"
      threshold                  = notification.value
      threshold_type             = "PERCENTAGE"
      notification_type          = "ACTUAL"
      subscriber_email_addresses = ["data-platform@acme.example"]
    }
  }
}
Tip

The cheapest query is the one a precomputed table answered; the cheapest cluster is the one that suspended; the cheapest data is what retention deleted. Cost control is mostly the disciplines of earlier modules, measured.

Hands-on practice

Platform as code, deployed by pipeline

  1. Write Terraform for a small data platform in a test account: a lake bucket with lifecycle and KMS encryption, an orchestrator role, a pipeline role scoped to read raw and write bronze, and a budget with alerts. Use var.env and apply it as dev.
  2. Add checkov to the plan step and confirm a deliberately public bucket fails.
  3. Create the CI workflow from the lesson for the repository holding your DAGs and dbt project: lint, unit tests, DAG parse check, slim dbt build against a ci target, governance tag check.
  4. Add the staging deploy on merge (sync DAGs to a bucket or a folder your local Airflow reads) and a production job gated by a GitHub environment approval. Merge a change and walk it through both.
  5. Build a masked development sample: a pipeline that takes 1% of production-like data, applies hash masking to PII columns per the tags, and writes it to a dev schema. Point your dbt dev target at it.
  6. Tag every resource and the warehouse queries with team and pipeline; run a cost query grouped by tag after a few runs.
  7. Destroy the dev environment with Terraform and recreate it, timing how long a full rebuild takes. That number is your platform's recoverability.
Cheat sheet

Data platform infrastructure — at a glance

Main things to focus on

  • Components with clear boundaries: ingestion, storage, SQL compute, code compute, orchestration, transformation code, governance, serving
  • Managed for the stateful hard parts; owned for models, pipelines, contracts, policies
  • Everything that outlives a run is Terraform, including warehouse schemas, roles and grants; policy checks on the plan
  • CI: lint, unit tests, DAG parse, slim dbt build, governance checks; CD: staging on merge, production on approval
  • Environments by target and var.env; masked samples for dev; separate accounts; promotion is a deploy
  • Cost: tag everything, budgets with alerts, per-domain compute, auto-suspend, lifecycle, weekly review

Terraform for data

aws_s3_bucket + lifecycle_configuration (IA at 90d, Glacier IR at 365d, expire)Storage tiering as code
server_side_encryption_configuration sse_algorithm=aws:kmsEncryption by default
snowflake_schema / snowflake_account_role / grant_privileges (future grants)Warehouse objects and access as code
aws_iam_role per pipeline, assumed by the orchestratorLeast-privilege identities
var.env in every nameSame code, three environments
checkov -d infra/ in CIPolicy before apply

CI/CD

ruff, sqlfluff lint --dialect X, pytest tests/unitFast checks on every PR
DagBag('dags').import_errors == {}DAGs parse
dbt build --select state:modified+ --state prod-artifacts --target ciSlim CI
aws s3 sync dags/ s3://.../dags/ --deleteDeploy DAGs to a managed orchestrator
environment: production (approval gate)Promotion needs a human
store manifest.json + run_results.json after prod runsArtifacts for slim CI and observability

Environments

dev: per-developer schema, masked 1% sample, weekly refreshFast, private, disposable
staging: prod structure, masked subset or restricted prod dataScale and integration rehearsal
prod: deployed code only, own identities and computeNo edits on the server
dbt profiles targets: dev / ci / staging / prodSelection by target, not by code branches
zero-copy clone / table snapshot for staging dataCheap realistic datasets

Cost

tags: team, pipeline, env on every resource and queryAttribution
aws_budgets_budget with 50/80/100% notificationsOwners find out first
warehouse per domain; auto-suspend; byte capsIsolation and limits
lifecycle + partition expirationStorage that shrinks
weekly top-drivers review with ownersUnexplained jump = incident

Common pitfalls

  • Warehouse roles and grants created by hand in the console, invisible to review and impossible to rebuild.
  • Editing DAGs directly on the orchestrator server to fix production quickly.
  • Copying full production data into every developer's schema, multiplying cost and privacy exposure.
  • if env == 'prod' branches in transformation logic, so staging never tests the production path.
  • One shared warehouse for every team, so nobody owns the bill.
  • Never rebuilding an environment from code, so the day it must be rebuilt is the day you learn it cannot be.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →