linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Введение
  • Уроки
  • How it works
  • База знаний
  • Шпаргалка
  • Capstone
  • Собеседование
home/terraform/lessons/tf-advanced-08-capstone

lesson ── terraform-advanced ── ~35 мин ── 7 шагов

Capstone, VPC + ALB + ECS Fargate + Lambda

The final capstone. You pull together everything you learned: modules (intermediate), testing (P2), the policy gate (P2), and terraform_remote_state for splitting stacks (advanced). The architecture is a typical mini platform: VPC, ALB, an ECS Fargate service, and a Lambda function. No EKS, LocalStack Community does not support it; ECS on Fargate is the close equivalent.

35 minutes is a lot. If you are short on time, do the first 4 steps (network + compute) and leave serverless and tests for a second pass.

▶ интерактивный sandbox

Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.

запустить sandbox →

stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя

Шаги

  1. 01

    Step 1: Network stack (VPC + subnets + IGW)

    bash
    cd /home/student/capstone/modules/network
    cat > main.tf <<'EOF'
    resource "aws_vpc" "main" {
      cidr_block           = "10.0.0.0/16"
      enable_dns_hostnames = true
      enable_dns_support   = true
      tags = { Name = "capstone-vpc", Stack = "network" }
    }
    resource "aws_subnet" "public" {
      count                   = 2
      vpc_id                  = aws_vpc.main.id
      cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
      availability_zone       = "us-east-1${["a", "b"][count.index]}"
      map_public_ip_on_launch = true
      tags = { Name = "public-${count.index}", Tier = "public" }
    }
    resource "aws_subnet" "private" {
      count             = 2
      vpc_id            = aws_vpc.main.id
      cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
      availability_zone = "us-east-1${["a", "b"][count.index]}"
      tags = { Name = "private-${count.index}", Tier = "private" }
    }
    resource "aws_internet_gateway" "main" {
      vpc_id = aws_vpc.main.id
      tags   = { Name = "capstone-igw" }
    }
    output "vpc_id" {
      value = aws_vpc.main.id
    }
    output "public_subnet_ids" {
      value = aws_subnet.public[*].id
    }
    output "private_subnet_ids" {
      value = aws_subnet.private[*].id
    }
    EOF
    cp ../../provider.tf .
    terraform init -no-color > /dev/null
    terraform apply -auto-approve -no-color 2>&1 | tail -3
    terraform output -json > /tmp/network-outputs.json
    cat /tmp/network-outputs.json | jq -r '.vpc_id.value'

    The network is deployed: 1 VPC, 4 subnets, 1 IGW. Outputs are saved.

    ✓ Network is ready. This stack will not need to change for a long time.

  2. 02

    Step 2: Compute (ALB + ECS Fargate)

    bash
    cd /home/student/capstone/modules/compute
    cat > main.tf <<'EOF'
    data "terraform_remote_state" "network" {
      backend = "local"
      config  = { path = "../network/terraform.tfstate" }
    }
    resource "aws_security_group" "alb" {
      name   = "capstone-alb-sg"
      vpc_id = data.terraform_remote_state.network.outputs.vpc_id
      ingress {
        from_port   = 80
        to_port     = 80
        protocol    = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
      }
      egress {
        from_port   = 0
        to_port     = 0
        protocol    = "-1"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }
    resource "aws_lb" "main" {
      name               = "capstone-alb"
      load_balancer_type = "application"
      security_groups    = [aws_security_group.alb.id]
      subnets            = data.terraform_remote_state.network.outputs.public_subnet_ids
    }
    resource "aws_lb_target_group" "app" {
      name        = "capstone-app-tg"
      port        = 8080
      protocol    = "HTTP"
      target_type = "ip"
      vpc_id      = data.terraform_remote_state.network.outputs.vpc_id
    }
    resource "aws_lb_listener" "http" {
      load_balancer_arn = aws_lb.main.arn
      port              = 80
      default_action {
        type             = "forward"
        target_group_arn = aws_lb_target_group.app.arn
      }
    }
    resource "aws_ecs_cluster" "main" {
      name = "capstone-cluster"
    }
    output "alb_dns" {
      value = aws_lb.main.dns_name
    }
    output "ecs_cluster_name" {
      value = aws_ecs_cluster.main.name
    }
    EOF
    cp ../../provider.tf .
    terraform init -no-color > /dev/null
    terraform apply -auto-approve -no-color 2>&1 | tail -3
    terraform state list

    Compute reads the network through terraform_remote_state. The coupling between stacks is explicit, through outputs.

    ✓ Compute is up. The ALB and the ECS cluster are ready.

  3. 03

    Step 3: Serverless (Lambda)

    bash
    cd /home/student/capstone/modules/serverless
    mkdir -p lambda-src
    cat > lambda-src/handler.py <<'EOF'
    def main(event, context):
        return {"statusCode": 200, "body": "ok"}
    EOF
    cat > main.tf <<'EOF'
    data "terraform_remote_state" "network" {
      backend = "local"
      config  = { path = "../network/terraform.tfstate" }
    }
    data "archive_file" "lambda" {
      type        = "zip"
      source_file = "${path.module}/lambda-src/handler.py"
      output_path = "${path.module}/lambda.zip"
    }
    resource "aws_iam_role" "lambda" {
      name = "capstone-lambda-role"
      assume_role_policy = jsonencode({
        Version = "2012-10-17"
        Statement = [{
          Action    = "sts:AssumeRole"
          Effect    = "Allow"
          Principal = { Service = "lambda.amazonaws.com" }
        }]
      })
    }
    resource "aws_cloudwatch_log_group" "lambda" {
      name              = "/aws/lambda/capstone-lambda"
      retention_in_days = 7
    }
    resource "aws_lambda_function" "demo" {
      function_name    = "capstone-lambda"
      filename         = data.archive_file.lambda.output_path
      source_code_hash = data.archive_file.lambda.output_base64sha256
      handler          = "handler.main"
      runtime          = "python3.12"
      role             = aws_iam_role.lambda.arn
      depends_on = [aws_cloudwatch_log_group.lambda]
    }
    output "lambda_arn" {
      value = aws_lambda_function.demo.arn
    }
    EOF
    cp ../../provider.tf .
    terraform init -no-color > /dev/null
    terraform apply -auto-approve -no-color 2>&1 | tail -3
    terraform state list

    Lambda with a CloudWatch log group and an IAM role. The stack is independent, it reads the network only for context (it does not use the VPC config in this example, but in practice you often read it for a VPC-attached Lambda).

    ✓ The serverless stack is deployed. Three stacks are running.

  4. 04

    Step 4: OPA policy gate

    Business rule: every resource carries a Stack tag. Check the compute plan:

    bash
    cd /home/student/capstone/modules/compute
    terraform plan -no-color -out=plan.tfplan > /dev/null
    terraform show -json plan.tfplan > plan.json
    mkdir -p ../../policies
    cat > ../../policies/tags.rego <<'EOF'
    package main
    import future.keywords.contains
    import future.keywords.if
    import future.keywords.in
    deny contains msg if {
        some resource in input.resource_changes
        # filter by type, only the large resources (not ALB listeners, etc.)
        resource.type in {"aws_lb", "aws_ecs_cluster", "aws_lambda_function", "aws_vpc"}
        resource.change.actions[_] == "create"
        tags := object.get(resource.change.after, "tags", {})
        not tags.Stack
        msg := sprintf("%s missing Stack tag", [resource.address])
    }
    EOF
    conftest test plan.json --policy ../../policies/ 2>&1 | head -10

    The compute resources have no Stack tags, so the gate should catch them.

    ✓ The policy gate fired. The main compute resources have no Stack tag.

  5. 05

    Step 5: Tests on the network module

    bash
    cd /home/student/capstone/modules/network
    mkdir -p tests
    cat > tests/network.tftest.hcl <<'EOF'
    mock_provider "aws" {}
    run "vpc_created_with_dns_enabled" {
      command = plan
      assert {
        condition     = aws_vpc.main.enable_dns_hostnames == true
        error_message = "VPC must have DNS hostnames enabled"
      }
      assert {
        condition     = aws_vpc.main.enable_dns_support == true
        error_message = "VPC must have DNS support"
      }
    }
    run "subnets_in_different_azs" {
      command = plan
      assert {
        condition     = aws_subnet.public[0].availability_zone != aws_subnet.public[1].availability_zone
        error_message = "public subnets must span at least 2 AZs"
      }
    }
    run "subnets_have_tier_tag" {
      command = plan
      assert {
        condition     = aws_subnet.public[0].tags["Tier"] == "public"
        error_message = "public subnet must have Tier=public tag"
      }
      assert {
        condition     = aws_subnet.private[0].tags["Tier"] == "private"
        error_message = "private subnet must have Tier=private tag"
      }
    }
    EOF
    terraform test 2>&1 | tail -10

    Three runs pass with mock_provider, no cloud needed.

    ✓ The network tests passed. The module contract is locked in.

  6. 06

    Step 6: Integration smoke test

    Check that the three stacks are wired together correctly:

    bash
    # network outputs
    cd /home/student/capstone/modules/network
    VPC_ID=$(terraform output -raw vpc_id)
    # does compute see the VPC?
    cd ../compute
    COMPUTE_VPC=$(terraform state show aws_lb_target_group.app | grep vpc_id | awk -F'"' '{print $2}')
    echo "network VPC:   $VPC_ID"
    echo "compute VPC:   $COMPUTE_VPC"
    if [ "$VPC_ID" = "$COMPUTE_VPC" ]; then
      echo "PASS: compute correctly references network VPC"
    else
      echo "FAIL: VPC mismatch"
    fi
    # does serverless see the same network?
    cd ../serverless
    SERVERLESS_LAMBDA=$(terraform state show aws_lambda_function.demo | grep function_name | awk -F'"' '{print $2}')
    echo "serverless Lambda: $SERVERLESS_LAMBDA"
    # finally
    aws --endpoint-url=http://localstack:4566 lambda list-functions \
      --query 'Functions[].FunctionName' --output text

    Notice: the compute VPC equals the network VPC, and the Lambda exists. The cross-stack references work.

    ✓ Three-stack integration works. Network, compute, and serverless read each other.

    The same thing on OpenTofu

    OpenTofu keeps its CLI and state compatible with Terraform for the commands in this step: a migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. But on the first switch, back up the state and do a run on a feature branch, the differences cluster in the newer features (variables in the backend, state encryption, OCI registry-backed modules). See tf-opentofu-parity for the full matrix.

    • → OpenTofu parity
  7. 07

    Step 7: Takeaways

    What the capstone showed:

    1. Splitting stacks by blast radius. Network changes rarely, compute often, serverless is independent. Each one has its own state and its own lock.

    2. terraform_remote_state as a contract. Only outputs cross between stacks. The internal structure of network is not visible to compute.

    3. Policy gate on plan.json. The OPA rule Stack-tag required catches the error before apply. In CI this gate blocks the merge.

    4. .tftest.hcl with mock_provider. Unit tests on the module without a cloud, seconds per test run.

    5. LocalStack as a full environment. You can learn, test, and run CI, all of it without an AWS bill.

    A real team extends this:

    • Terragrunt or Stacks for multi-env (dev/stage/prod).
    • OIDC for CI runners.
    • Drift detection on a cron.
    • An Infracost gate on cost impact.
    • A custom provider if there is an internal API.

    All of this is covered in the KB; this capstone is a starting point, not the end.

    bash
    echo "TerraformLab complete."

    ✓ The course is complete. From here on, real work.

    What is not covered (and where it lives in the real world)

    This capstone is a minimum-viable production stack. Not covered:

    1. Stateful storage, RDS, DynamoDB. See the provider docs.
    2. CDN + WAF, CloudFront + WAF rules.
    3. Observability, CloudWatch dashboards, alarms, X-Ray.
    4. Multi-region, Route53 latency routing, cross-region DR.
    5. Multi-account, AWS Organizations, AssumeRole patterns.
    6. EKS / Kubernetes, a separate domain; see tf-when-not-to-use on the boundaries of Terraform.

    Hashicorp and Gruntwork have published reference architectures that are a useful next step after this course:

    • aws-reference-architecture (Gruntwork)
    • terraform-aws-modules (registry, maintained modules)

    Do not try to write everything from scratch, there are ready modules that thousands of teams use.

    • → State at scale
    • → Multi-env with Terragrunt
    • → Boundaries of Terraform

Что ты узнал

A multi-stack architecture: network (VPC, subnets), compute (ALB, ECS, target groups), serverless (Lambda, log group). Between them, terraform_remote_state. The policy gate runs through OPA, the tests through .tftest.hcl with mock_provider.

команды

  • terraform apply -auto-approvedeploys a single stack.
  • terraform testruns the unit tests for the modules.
  • conftest test plan.json --policy policies/policy gate before apply.

концепции

  • · Network stack, rarely changes, huge blast radius
  • · Compute stack, frequent changes (deployments), medium blast radius
  • · Serverless, independent, reads only the network outputs

← предыдущий

Plan as an artifact, between PR and apply

следующий →

count vs for_each: creating resources in bulk

Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies