Ron and Ella Wiki Page

Extremely Serious

Why We Need Modern Software and Tools?

Modern software and tools are no longer “nice to have”; they are the infrastructure that lets individuals and organizations work faster, more accurately, and more securely in a digital economy.

The role of modern tools in today’s world

We now build, run, and maintain most services through software, from banking and healthcare to logistics and entertainment. Modern tools encapsulate current best practices, regulations, and technologies, allowing us to keep up with rapidly changing requirements and expectations.

Efficiency and productivity at scale

Modern tools automate repetitive work such as deployments, testing, reporting, and coordination, which dramatically reduces manual effort and context switching. This automation scales: one team can now manage systems that would previously have required many more people, simply because the tools handle orchestration and routine checks.

Accuracy, reliability, and reduced risk

Contemporary platforms embed validation, type checking, automated tests, and monitoring capabilities that reduce the likelihood of human error. As a result, systems become more reliable, analytics more trustworthy, and business decisions less exposed to mistakes arising from inconsistent or incorrect data.

Collaboration in a distributed world

Work has become inherently distributed across locations and time zones, and modern software is designed to support this reality. Shared repositories, real‑time document and code collaboration, integrated chat, and task tracking make it feasible for cross‑functional teams to coordinate effectively without being physically co‑located.

Security, compliance, and maintainability

Security threats evolve constantly, and older tools tend not to receive timely patches or support for new standards. Modern platforms incorporate stronger authentication, encryption, audit trails, and compliance features, helping organizations protect data and meet regulatory obligations while keeping maintenance overhead manageable.

Innovation and competitive advantage

New capabilities—AI-assisted development, advanced analytics, low‑code platforms, cloud‑native services—are exposed primarily through modern tools and ecosystems. Organizations that adopt them can experiment faster, ship features more quickly, and create better user experiences, while those tied to outdated tooling tend to move slowly and lose competitive ground.

In short, we use modern software and tools because they are the practical way to achieve speed, quality, security, and innovation in a world where all of these are moving targets.

Cloud Native Applications and the Twelve‑Factor Methodology

Cloud native and the twelve‑factor methodology describe two tightly related but distinct layers of modern software: cloud native is primarily about the environment and platform you deploy to, while twelve‑factor is about how you design and implement the application so it thrives in that environment.

What “cloud native” actually means

Cloud‑native applications are designed to run on dynamic, elastic infrastructure such as public clouds, private clouds, or hybrid environments. They assume that:

  • Infrastructure is ephemeral: instances can disappear and be recreated at any time.
  • Scale is horizontal: you handle more load by adding instances, not vertically scaling a single machine.
  • Configuration, networking, and persistence are provided by the platform and external services, not by local machine setup.

Typically, cloud‑native systems use:

  • Containers (OCI images) as the primary packaging and deployment unit.
  • Orchestration (e.g., Kubernetes) to schedule, scale, heal, and roll out workloads.
  • Declarative configuration and infrastructure‑as‑code to describe desired state.
  • Observability (logs, metrics, traces) and automation (CI/CD, auto‑scaling, auto‑healing) as first‑class concerns.

From an architect’s perspective, “cloud native” is the combination of these platform capabilities with an application design that can exploit them. Twelve‑factor is one of the earliest and still influential descriptions of that design.

The twelve‑factor app in a nutshell

The twelve‑factor methodology was introduced to codify best practices for building Software‑as‑a‑Service applications that are:

  • Portable across environments.
  • Easy to scale horizontally.
  • Amenable to continuous deployment.
  • Robust under frequent change.

The original factors (Codebase, Dependencies, Config, Backing services, Build/Release/Run, Processes, Port binding, Concurrency, Disposability, Dev/prod parity, Logs, Admin processes) constrain how you structure and operate the app. The key idea is that by following these constraints, you produce an application that is:

  • Stateless in its compute tier.
  • Strict about configuration boundaries.
  • Explicit about dependencies.
  • Friendly to automation and orchestration.

Notice how those properties line up almost one‑for‑one with cloud‑native expectations.

How twelve‑factor underpins cloud‑native properties

Let’s connect specific twelve‑factor principles to core cloud‑native characteristics.

Portability and containerization

Several factors directly support packaging and running your app in containers:

  • Dependencies: All dependencies are declared explicitly and isolated from the base system. This maps naturally to container images, where your application and its runtime are packaged together.
  • Config: Configuration is stored in the environment, not baked into the image. That means the same image can be promoted across environments (dev → test → prod) simply by changing environment variables, ConfigMaps, or Secrets.
  • Backing services: Backing services (databases, queues, caches, etc.) are treated as attached resources, accessed via configuration. This decouples code from specific infrastructure instances, making it easy to bind to managed cloud services.

Result: your artifact (image) becomes environment‑agnostic, which is a prerequisite for true cloud‑native deployments across multiple clusters, regions, or even cloud providers.

Statelessness and horizontal scalability

Cloud‑native platforms shine when workloads are stateless and scale horizontally. Several factors enforce that:

  • Processes: The app executes as one or more stateless processes; any persistent state is stored in external services.
  • Concurrency: Scaling is achieved by running multiple instances of the process rather than threading tricks inside a single instance.
  • Disposability: Processes are fast to start and stop, enabling rapid scaling, rolling updates, and failure recovery.

On an orchestrator like Kubernetes, these characteristics translate directly into:

  • Replica counts controlling concurrency.
  • Pod restarts and rescheduling being safe and routine.
  • Auto‑scaling policies that can add or remove instances in response to load.

If your app violates these factors (e.g., uses local disk for state, maintains sticky in‑memory sessions, or takes minutes to start), it fights the cloud‑native platform rather than benefiting from it.

Reliability, operability, and automation

Cloud‑native systems rely heavily on automation and observability. Twelve‑factor anticipates this:

  • Dev/prod parity: Minimizing the gap between development, staging, and production environments reduces surprises and supports continuous delivery.
  • Logs: Treating logs as an event stream, written to stdout/stderr, fits perfectly with container logging and centralized log aggregation. The platform can capture, ship, and index logs without the application managing log files.
  • Admin processes: One‑off tasks (migrations, batch jobs) run as separate processes (or jobs), using the same codebase and configuration as long‑running services. This aligns with Kubernetes Jobs/CronJobs or serverless functions.

Together, these make it far easier to build reliable CI/CD pipelines, perform safe rollouts/rollbacks, and operate the system with minimal manual intervention—hallmarks of cloud‑native operations.

How to use twelve‑factor as a cloud‑native checklist

you can treat twelve‑factor as a practical assessment framework for cloud‑readiness of an application, regardless of language or stack.

For each factor, ask: “If I deployed this on a modern orchestrator, would this factor hold, or would it cause friction?” For example:

  • Config: Can I deploy the same container image to dev, QA, and prod, changing only environment settings? If not, there is a cloud‑native anti‑pattern.
  • Processes & Disposability: Can I safely kill any instance at any time without data loss and with quick recovery? If not, the app is not truly cloud‑native‑friendly.
  • Logs: If I run multiple instances, can I still understand system behavior from aggregated logs, or is there stateful, instance‑local logging?

You will usually discover that bringing a legacy application “into Kubernetes” without addressing these factors leads to brittle deployments: liveness probes fail under load, rollouts are risky, and scaling is unpredictable.

Conversely, if an app cleanly passes a twelve‑factor review, it tends to behave very well in a cloud‑native environment with minimal additional work.

How to position twelve‑factor today

Twelve‑factor is not the whole story in 2026, but it remains an excellent baseline:

  • It does not cover all modern concerns (e.g., multi‑tenant isolation, advanced security, service mesh, zero‑trust networking, event‑driven patterns).
  • It is, however, an excellent “minimum bar” for application behavior in a cloud‑native context.

I recommend treating it as:

  • A design standard for service teams: code reviews and design docs should reference the factors explicitly where relevant.
  • A readiness checklist before migrating a service to a Kubernetes cluster or similar platform.
  • A teaching tool for new engineers to understand why “just dockerizing the app” is not enough.

Scaffolding a Modern VS Code Extension with Yeoman

In this article we focus purely on scaffolding: generating the initial VS Code extension project using the Yeoman generator, with TypeScript and esbuild, ready for you to start coding.


Prerequisites

Before you scaffold the project, ensure you have:

  • Node.js 18+ installed (check with node -v).
  • Git installed (check with git --version).

These are required because the generator uses Node, and the template can optionally initialise a Git repository for you.


Generating the extension with Yeoman

VS Code’s official generator is distributed as a Yeoman generator. You don’t need to install anything globally; you can invoke it directly via npx:

# One-time scaffold (no global install needed)
npx --package yo --package generator-code -- yo code

This command:

  • Downloads yo (Yeoman) and generator-code on demand.
  • Runs the VS Code extension generator.
  • Prompts you with a series of questions about the extension you want to create.

Recommended answers to the generator prompts

When the interactive prompts appear, choose:

? What type of extension do you want to create? → New Extension (TypeScript)
? What's the name of your extension?            → my-ai-extension
? What's the identifier?                        → my-ai-extension
? Initialize a git repository?                  → Yes
? Which bundler to use?                         → esbuild
? Which package manager?                        → npm

Why these choices matter:

  • New Extension (TypeScript) – gives you a typed development experience and a standard project layout.
  • Name / Identifier – the identifier becomes the technical ID used in the marketplace and in settings; pick something stable and lowercase.
  • Initialize a git repository – sets up Git so you can immediately start version-controlling your work.
  • esbuild – a modern, fast bundler that creates a single bundled extension.js for VS Code.
  • npm – a widely used default package manager; you can adapt to pnpm/yarn later if needed.

After you answer the prompts, Yeoman will generate the project in a new folder named after your extension (e.g. my-ai-extension).


Understanding the generated structure

Open the new folder in VS Code. The generator gives you a standard layout, including:

  • src/extension.ts
    This is the entry point of your extension. It exports activate and (optionally) deactivate. All your activation logic, command registration, and other behaviour start here.
  • package.json
    This acts as the extension manifest. It contains:

    • Metadata (name, version, publisher).
    • "main" field pointing to the compiled bundle (e.g. ./dist/extension.js).
    • "activationEvents" describing when your extension loads.
    • "contributes" describing commands, configuration, views, etc., that your extension adds to VS Code.

From an architectural perspective, package.json is the single most important file: it tells VS Code what your extension is and how and when it integrates into the editor.

You’ll also see other generated files such as:

  • tsconfig.json – TypeScript compiler configuration.
  • Build scripts in package.json – used to compile and bundle the extension with esbuild.
  • .vscode/launch.json – debug configuration for running the extension in a development host.

At this stage, you don’t need to modify any of these to get a working scaffold.


Running the scaffolded extension

Once the generator finishes:

  1. Install dependencies:

    cd my-ai-extension
    npm install
  2. Open the folder in VS Code (if you aren’t already).

  3. Press F5.

    VS Code will:

    • Run the build task defined by the generator.
    • Launch a new Extension Development Host window.
    • Load your extension into that window.

In the Extension Development Host:

  • Open the Command Palette.
  • Run the sample command that the generator added (typically named something like “Hello World”).

If the command runs and shows the sample notification, you have a fully working scaffolded extension. From here, you can start replacing the generated sample logic in src/extension.ts and adjusting package.json to declare your own contributions.

Building With Terraform Modules

Terraform modules are how you turn raw Terraform into a reusable, versioned “library” of infrastructure components. In this article we’ll go through what modules are, the types you’ll see in practice, how to create them, when to factor code into a module, how to update them safely, how to publish them, and finally how to consume them from your stacks.


What is a Terraform module?

At its core, a module is just a directory containing Terraform configuration that can be called from other Terraform code.

  • Any directory with .tf files is a module.
  • The directory where you run terraform init/plan/apply is your root module.
  • A root module can call child modules via module blocks, which is how you achieve reuse and composition.

Conceptually, a module is like a function in code:

  • Inputs → variables
  • Logic → resources, locals, data sources
  • Outputs → values other code can depend on

Good modules hide internal complexity behind a clear, minimal interface, exactly as you’d expect from a well‑designed API.


Types of modules you’ll deal with

In practice you’ll encounter several “types” or roles of modules:

  1. Root module
    • The entrypoint of a stack (e.g. envs/prod), where you configure providers, backends, and call other modules.
    • Represents one deployable unit: a whole environment, a service, or a single app stack.
  2. Child / reusable modules
    • Reusable building blocks: VPCs, EKS clusters, RDS databases, S3 buckets, etc.
    • Usually live under modules/ in a repo, or in a separate repo entirely.
    • Called from root or other modules with module "name" { ... }.
  3. Public registry modules
    • Published to the public Terraform Registry, versioned and documented.
    • Example: terraform-aws-modules/vpc/aws
    • Great for standard primitives (VPCs, security groups, S3, etc.), less so for business‑specific patterns.
  4. Private/organizational modules
    • Hosted in private registries or Git repos.
    • Usually represent your organization’s conventions and guardrails (“a compliant VPC”, “a hardened EKS cluster”).

Architecturally, many teams settle on layers:

  • Layer 0: cloud and providers (root module).
  • Layer 1: platform modules (VPC, KMS, logging, IAM baselines).
  • Layer 2: product/service modules (service X, API Y) that compose platform modules.

Creating a Terraform module

Standard structure

A well‑structured module typically has:

  • main.tf – core resources and module logic
  • variables.tf – input interface
  • outputs.tf – exported values
  • versions.tf (optional but recommended) – provider and Terraform version constraints
  • README.md – usage, inputs, outputs, examples

This structure is not required by Terraform but is widely used because it keeps interfaces clear and tooling friendly.

Simple working example

Let’s build a small AWS S3 bucket module and then consume it from a root module.

Module: modules/aws_s3_bucket

modules/aws_s3_bucket/versions.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

modules/aws_s3_bucket/variables.tf:

variable "bucket_name" {
  type        = string
  description = "Name of the S3 bucket."
}

variable "environment" {
  type        = string
  description = "Environment name (e.g., dev, prod)."
  default     = "dev"
}

variable "extra_tags" {
  type        = map(string)
  description = "Additional tags to apply to the bucket."
  default     = {}
}

modules/aws_s3_bucket/main.tf:

resource "aws_s3_bucket" "this" {
  bucket = var.bucket_name

  tags = merge(
    {
      Name        = var.bucket_name
      Environment = var.environment
    },
    var.extra_tags
  )
}

modules/aws_s3_bucket/outputs.tf:

output "bucket_id" {
  description = "The ID (name) of the bucket."
  value       = aws_s3_bucket.this.id
}

output "bucket_arn" {
  description = "The ARN of the bucket."
  value       = aws_s3_bucket.this.arn
}

Rationale:

  • variables.tf defines the module’s public input contract.
  • outputs.tf defines the public output contract.
  • versions.tf protects you from incompatible provider/Terraform versions.
  • main.tf stays focused on resources and any derived locals.

Root module consuming it

In your root directory (e.g. project root):

versions.tf:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}

providers.tf:

provider "aws" {
  region                      = "us-east-1"

  # Fake credentials for LocalStack
  access_key                  = "test"
  secret_key                  = "test"

  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true
  s3_use_path_style           = true

  # Point AWS services at LocalStack
  endpoints {
    s3 = "http://localhost:4566"
    # add more if needed, e.g. dynamodb = "http://localhost:4566"
  }
}

variables.tf:

variable "aws_region" {
  type        = string
  description = "AWS region to deploy into."
  default     = "ap-southeast-2"
}

variable "environment" {
  type        = string
  description = "Environment name."
  default     = "dev"
}

main.tf:

module "logs_bucket" {
  source      = "./modules/aws_s3_bucket"
  bucket_name = "my-org-logs-${var.environment}"
  environment = var.environment
  extra_tags = {
    owner = "platform-team"
  }
}

output "logs_bucket_arn" {
  value       = module.logs_bucket.bucket_arn
  description = "Logs bucket ARN."
}

How to validate this example

From the root directory:

  1. Start LocalStack (for example, via Docker):

    docker run --rm -it -p 4566:4566 -p 4510-4559:4510-4559 localstack/localstack

    This exposes the LocalStack APIs on http://localhost:4566 as expected by the provider config.

  2. terraform init

  • Ensures Terraform and the AWS provider are set up; discovers the local module.
  1. terraform validate
  • Confirms syntax, types, required variables satisfied.
  1. terraform plan
  • You should see one S3 bucket to be created, with the name my-org-logs-dev by default.
  • Confirm that the tags include Environment = dev and owner = platform-team.
  1. terraform apply
  • After apply, run terraform output logs_bucket_arn and check that:
    • The ARN looks correct for your region.
    • The bucket exists in AWS with expected tags.

If these checks pass, your module and consumption pattern are wired correctly.


When to create a module

You should not modularise everything; the trick is to modularise at the right abstraction boundaries.

Good reasons to create a module

  • You’re copy‑pasting the same pattern across stacks or repos
    • Example: the same cluster pattern for dev, stage, prod.
    • A module eliminates duplication and concentrates fixes in one place.
  • You have a logical component with a clear responsibility
    • Examples: “networking”, “observability stack”, “Generic service with ALB + ECS + RDS”.
    • Each becomes a module with focused inputs and outputs.
  • You want to hide complexity and provide sane defaults
    • Consumers shouldn’t need to know every IAM policy detail.
    • Provide a small set of inputs; encode your standards inside the module.
  • You want a contract between teams
    • Platform team maintains modules; product teams just configure inputs.
    • This aligns nicely with how you manage APIs or libraries internally.

When not to create a module (yet)

  • One‑off experiments or throwaway code.
  • A single, simple resource that is unlikely to be reused.
  • When you don’t yet understand the pattern — premature modularisation leads to awkward, unstable interfaces.

A good heuristic: if you’d be comfortable writing a README with “what this does, inputs, outputs” and you expect re‑use, it’s a good module candidate.


Updating a module safely

Updating modules has two dimensions: changing the module itself, and rolling out the updated version to consumers.

Evolving the module interface

Prefer backwards‑compatible changes when possible:

  • Add new variables with sensible defaults instead of changing existing ones.
  • Add new outputs without altering the meaning of existing outputs.
  • If you must break behaviour, bump a major version and document the migration path.

Internally you might refactor resources, adopt new provider versions, or change naming conventions, but keep the external contract as stable as you can.

Versioning strategy

For modules in a separate repo or registry:

  • Use semantic versioning: MAJOR.MINOR.PATCH.
    • PATCH: bugfixes, no breaking changes.
    • MINOR: new optional features, backwards compatible.
    • MAJOR: breaking changes.

Tag releases (v1.2.3) and use those tags in consumers (Git or registry).

Rolling out updates to consumers

For a Git‑sourced module:

module "logs_bucket" {
  source  = "git::https://github.com/my-org/terraform-aws-s3-bucket.git?ref=v1.3.0"
  # ...
}

To upgrade:

  1. Change ref from v1.2.0 to v1.3.0.
  2. Run terraform init -upgrade.
  3. Run terraform plan and review changes carefully.
  4. Apply in lower environments first, then promote the same version to higher environments (via branch promotion, pipelines, or workspace variables).

For a registry module, the pattern is the same but with a version argument:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.3.0"
}

Pinning versions gives you reproducibility and avoids surprise changes across environments.


Publishing a module

Publishing is about making your module discoverable and consumable by others, with strong versioning and documentation.

Public registry (high‑level)

To publish a module publicly (e.g. to the Terraform Registry):

  • Place the module in a public VCS repo (commonly GitHub).
  • Name the repo using the convention: terraform-<PROVIDER>-<NAME>
    • Example: terraform-aws-s3-bucket.
  • Ensure the repo root contains your module (main.tf, variables.tf, outputs.tf, etc.).
  • Tag a version (e.g. v1.0.0).
  • Register the module on the registry UI (linking your VCS account).

Once indexed, users can consume it as:

module "logs_bucket" {
  source  = "my-org/s3-bucket/aws"
  version = "1.0.0"

  bucket_name = "my-org-logs-prod"
  environment = "prod"
}

Private registries and Git

For internal usage, many organizations prefer:

  • Private registry (Terraform Cloud/Enterprise, vendor platform, or self‑hosted).
    • Similar flow to the public registry, but scoped to your org.
  • Direct Git usage
    • Modules are consumed from Git with ?ref= pointing to tags or commits.
    • Simpler setup, but you lose some of the browsing and discoverability that registries provide.

The key idea is the same: modules are versioned artefacts, and consumers should pin versions and upgrade intentionally.


Consuming modules (putting it all together)

To consume any module, you:

  1. Add a module block.
  2. Set source to a local path, Git URL, or registry identifier.
  3. Pass the required inputs as arguments.
  4. Use the module’s outputs via module.<name>.<output_name>.

Example: consuming a local network module and a registry VPC module side by side.

# Local module (your own)
module "network" {
  source = "./modules/network"

  vpc_cidr        = "10.0.0.0/16"
  public_subnets  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
}

# Registry module (third-party)
module "logs_bucket" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "~> 4.0"

  bucket = "my-org-logs-prod"

  tags = {
    Environment = "prod"
  }
}

output "network_vpc_id" {
  value = module.network.vpc_id
}

output "logs_bucket_arn" {
  value = module.logs_bucket.s3_bucket_arn
}

The root module becomes a composition layer, wiring together multiple modules rather than directly declaring many low‑level resources.


Summary of key practices

  • Treat modules as APIs: clear inputs, clear outputs, stable contracts.
  • Use a predictable structure: main.tf, variables.tf, outputs.tf, versions.tf, README.md.
  • Only create modules where there is clear reuse or a meaningful abstraction.
  • Version modules and pin those versions when consuming them.
  • Use lower environments and terraform plan to validate updates before promoting.

The Core GitHub Copilot AI Primitives in VS Code

The primitives in this ecosystem are the building blocks you compose to turn a generic model into a team‑specific coding assistant: instruction files, skills, prompts, custom agents (and sub‑agents), and hooks. Think of them as layers: always‑on rules at the bottom, on‑demand capabilities on top, and automation wrapped around the lifecycle.


1. Instruction files: Persistent rules and context

Instruction files are Markdown configurations that Copilot always includes in the context when working in your repo or specific files.

  • They live alongside your code (for example .instructions.md or repo‑level instruction files) and often use glob patterns to target languages or folders.
  • You capture architecture decisions, coding standards, naming conventions, security constraints, and “how this codebase works” so the agent doesn’t guess.
  • File‑ or pattern‑scoped instructions let you tune behavior per domain (e.g., frontend vs. backend vs. infra scripts).

Rationale: This is your “always‑on brain” for the codebase; you remove prompt repetition and make the agent opinionated in the same way your senior engineers are.


2. Skills: On‑demand specialized capabilities

Skills are folders (with SKILL.md) that define how to perform a specialized task, plus any helper scripts or examples.

  • SKILL.md contains YAML frontmatter (metadata) and instructions describing when and how to use the skill.
  • Copilot decides when to inject a skill into context based on the user’s request and the skill description—for example “debug input handling for this game” or “migrate legacy API calls.”
  • Skills are ideal for repeatable domain tasks: debugging patterns, migration playbooks, data‑access rules, or company‑specific frameworks.

Rationale: Instructions describe global rules, while skills encode detailed procedures that are only loaded when relevant, keeping the context window efficient.


3. Prompts: Reusable slash‑command workflows

Prompt files define named prompts that appear as slash commands (e.g., /test, /document, /refactor) inside Copilot chat.

  • They bundle a task pattern, guidance, and sometimes specific tools into a reusable command your team can trigger instantly.
  • Typical uses: generate tests for the current file, summarize a diff, propose a refactor plan, or scaffold a feature implementation outline.
  • Prompts can be tailored per repo so their behavior reflects local conventions and dependencies.

Rationale: Prompts are UX primitives for humans: they standardize how people ask for common operations, reducing prompt variability and making outcomes more predictable.


4. Custom agents and sub‑agents: Role‑based specialization

Custom agents are defined via agent config files (for example .agent.md under .github/agents) that describe a persona, its tools, and its behavior.

  • The frontmatter configures name, description, tools (built‑in tools and MCP servers), model, and where the agent is available.
  • The Markdown body defines its role, expertise, boundaries, and how it should respond—for example “Solution Architect,” “Security Reviewer,” or “Test‑first Implementer.”
  • These agents appear in the chat agent dropdown and can be invoked directly for tasks that match their specialization.

Sub‑agents are agents that run under an orchestrator agent to handle subtasks in parallel.

  • The orchestrator can delegate subtasks like planning, implementation, accessibility review, and cleanup to different agents, each working in its own context.
  • Only distilled results return to the orchestrator, preventing its context from being flooded with every intermediate step.

Rationale: This mirrors a real engineering team: you encode roles and responsibilities into agents, then let them collaborate while preserving clear separation of concerns and cleaner context windows.


5. Hooks: Lifecycle automation and policy enforcement

Hooks are shell commands that run at key lifecycle points of an agent session, configured via hook files described in the docs.

  • They can trigger on events like session start/stop, agent or sub‑agent start/stop, before or after a tool call, or before/after edits are applied.
  • Hooks receive JSON input describing what the agent is doing, and can decide to log, transform, veto, or augment actions (for example enforce formatting, run linters, or perform security checks before committing changes).
  • Output from hooks can influence whether the agent continues, rolls back, or adjusts its plan.

Rationale: Hooks move important practices (lint, tests, security, approvals) from “please remember” into enforced automation, embedding your governance into the agent runtime itself.


6. How the primitives fit together

Taken together, these primitives give you a layered design:

  • Instruction files: stable background knowledge and guardrails.
  • Skills: contextual, task‑specific playbooks the agent loads when needed.
  • Prompts: ergonomic entry points for common user workflows.
  • Custom agents and sub‑agents: specialized roles and multi‑agent orchestration.
  • Hooks: lifecycle glue for automation, quality, and compliance.

Understanding State with show, state, and output

Terraform’s state is how it “remembers” what exists in your infrastructure so it can plan precise, minimal changes instead of blindly recreating resources. In this article, we’ll treat Terraform as a black box and learn how to inspect its memory using three key CLI tools: terraform show, the terraform state subcommands, and terraform output.


1. What Terraform State Actually Is

Terraform keeps a mapping between:

  • Your configuration (.tf files)
  • The real resources in your cloud provider (IDs, IPs, ARNs, etc.)

This mapping lives in a state file, usually terraform.tfstate, and often in a remote backend such as S3, Azure Blob Storage, or GCS for team use. The state includes every attribute of every managed resource, plus metadata used for things like dependency ordering and change detection.

Why you care:

  • Debugging: Is Terraform seeing the same thing you see in the console?
  • Refactoring: How do you rename resources without destroying them?
  • Automation: How do you feed outputs into CI/CD or other tools?

You should never hand-edit the state file; instead you use the CLI commands discussed below to read or safely modify it.


2. terraform show — Inspecting the Whole State or a Plan

Think of terraform show as “dump what Terraform currently knows” — it turns a state file or a saved plan into a human-readable or JSON view.

Core usage

# Show the current state snapshot (from the active backend)
terraform show

# Show a specific state file
terraform show path/to/terraform.tfstate

# Show a saved plan file
terraform show tfplan

# Machine-readable JSON for tooling
terraform show -json > plan.json
  • Without a file argument, terraform show prints the latest state snapshot from the active backend.
  • With a plan file, it describes the proposed actions and resulting state.
  • With -json, you get a structured document that external tools (e.g. CI, tests) can parse and validate.

Important: When using -json, sensitive values are printed in plain text; handle this carefully in pipelines and logs.

When to use terraform show

Use it when:

  • You want a global view: “What exactly is Terraform tracking right now?”
  • You want to inspect a plan artifact (plan -out tfplan) before approving it in CI.
  • You want to feed state or plan data into a tool (via -json) for policy checks, drift checks, or custom validation.

Conceptually, terraform show is read-only and holistic: it treats the state (or plan) as a whole, rather than individual resources.


3. terraform state — Fine-Grained State Inspection and Surgery

The terraform state command is a group of subcommands designed specifically to inspect and modify state without touching real infrastructure. This is the surgical toolkit you reach for when refactoring or repairing.

Key subcommands

Command What it does Typical use
terraform state list Lists all resource addresses in state “What is Terraform tracking?”
terraform state show ADDRESS Shows attributes of one resource Debugging one resource (IDs, IPs, tags, etc.)
terraform state mv SRC DEST Moves/renames a resource in state Refactors config without destroy/recreate
terraform state rm ADDRESS Removes a resource from state Stop managing a resource without deleting it
terraform state pull Prints raw state to stdout Backup, inspection, or external processing
terraform state push Uploads a local state file Restore/correct broken remote state (used rarely, carefully)

3.1 terraform state list

terraform state list
# e.g.
# aws_instance.web[0]
# aws_instance.web[1]
# aws_security_group.allow_ssh

This gives you the resource addresses Terraform knows about, optionally filtered by a prefix. It’s extremely useful when working with modules or count/for_each, because you can see the exact address Terraform expects.

3.2 terraform state show

terraform state show aws_instance.web[0]

This prints every attribute of that specific resource as seen in state — IDs, IPs, tags, relationships, and computed attributes. Semantically, it answers: “What does Terraform think this one resource looks like?”.

Use it when:

  • Debugging drift: console vs state mismatch.
  • Understanding complex resources: which subnet, which IAM role?
  • Checking data sources that were resolved at apply time.

Note the difference:

  • terraform show → everything (or full plan).
  • terraform state show ADDRESS → one resource only.

3.3 terraform state mv — Refactor Without Downtime

terraform state mv aws_instance.web aws_instance.app

If you simply rename the block in your .tf code, Terraform will plan to destroy the old resource and create a new one because it assumes they’re unrelated. state mv tells Terraform that the underlying resource is the same, you’re just changing the mapping.

This is critical for:

  • Renaming resources.
  • Moving resources into/out of modules.
  • Splitting a monolith configuration into multiple modules/workspaces.

3.4 terraform state rm — Stop Managing Without Deleting

terraform state rm aws_instance.legacy

This removes the resource from Terraform’s management while leaving it alive in your provider. Use this when decommissioning Terraform from part of your estate or when you temporarily need Terraform to “forget” something (e.g. migration to a different tool).

3.5 terraform state pull / push

These expose and manipulate the raw state blob:

terraform state pull > backup.tfstate
terraform state push backup.tfstate

They’re useful for backups or extremely rare recovery scenarios, but they’re dangerous if misused, so in practice you rely much more on list, show, mv, and rm.


4. terraform output — Consuming State Safely

terraform output reads output values defined in the root module and prints their values from the state file. It is the “official interface” for other systems (and humans) to consume selected bits of state without parsing the state file directly.

4.1 Defining outputs in configuration

In your root module:

output "instance_ips" {
  value = aws_instance.web[*].public_ip
}

output "lb_address" {
  value = aws_lb.web.dns_name
}

output "db_connection_string" {
  value     = module.database.connection_string
  sensitive = true
}
  • Outputs are calculated after terraform apply and stored in state.
  • Only root module outputs are visible to terraform output; child module outputs must be re-exposed.

4.2 Using terraform output interactively

# Show all outputs for the root module
terraform output

# Show one specific output
terraform output lb_address

# Machine-readable JSON
terraform output -json

# Raw string (no quotes/newlines), perfect for scripts
terraform output -raw lb_address
  • With no arguments, it prints all root outputs.
  • With a NAME, it prints just that value.
  • -json gives a JSON object keyed by output name; can be piped into jq or similar tools.
  • -raw prints a bare string/number/boolean; ideal when exporting in shell scripts without extra quoting.

This is the idiomatic way to feed state into:

  • CI/CD pipelines (e.g. get ALB DNS for integration tests).
  • Other scripts (e.g. configure DNS records).
  • Other tools (e.g. Ansible inventory).

5. Putting It Together: A Simple Example

Below is a minimal, self-contained configuration you can run locally.

5.1. Prerequisites

  1. LocalStack running (Docker is typical):

    docker run --rm -it -p 4566:4566 -p 4510-4559:4510-4559 localstack/localstack

    LocalStack’s edge endpoint is exposed on http://localhost:4566 by default.

  2. Terraform installed (1.x).

5.2. Terraform Configuration Using LocalStack

Create a directory (for example tf-localstack-ec2) and within it create two files: versions.tf and main.tf.

versions.tf

Lock AWS provider to a version that is known to work well with LocalStack (4.x is a common choice):

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

main.tf

provider "aws" {
  region                      = "us-east-1"
  access_key                  = "test"
  secret_key                  = "test"
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    ec2 = "http://localhost:4566"
  }
}

resource "aws_instance" "web" {
  ami           = "ami-12345678"
  instance_type = "t3.micro"

  tags = {
    Name = "tf-demo-web-localstack"
  }
}

output "web_public_ip" {
  value = aws_instance.web.public_ip
}

Notes:

  • The endpoints.ec2 block points the EC2 API at LocalStack’s edge endpoint.
  • Credentials are dummy; LocalStack doesn’t actually validate them.
  • The AMI ID is a placeholder; LocalStack typically does not require the AMI to exist, but EC2 support is limited and can hang for some combinations. For state/command learning it’s usually enough that Terraform “thinks” it created something.

5.3. How to Apply and Validate

From the directory containing these files:

  1. Initialize and apply

    terraform init
    terraform apply -auto-approve

    Terraform will talk to LocalStack instead of AWS because of the custom endpoint.

  2. Validate with show

    terraform show

    Confirm there is a aws_instance.web block with attributes populated from LocalStack’s response.

  3. Validate with state

    terraform state list
    # should include:
    # aws_instance.web
    
    terraform state show aws_instance.web

    This tells you what Terraform’s state holds for this specific resource address.

  4. Validate with output

    terraform output web_public_ip
    terraform output -raw web_public_ip

    For LocalStack, the public IP may be empty or synthetic depending on EC2 emulation level, but the command proves the wiring from resource → state → output.

5.4. Rationale for These Choices

  • We override only the EC2 endpoint to keep the example close to “real” AWS code while still talking to LocalStack.
  • We relax provider validations (skip_* flags) because LocalStack does not implement all AWS account/metadata APIs.

With this setup, you can safely experiment with terraform show, terraform state *, and terraform output on your laptop, without touching real AWS accounts or incurring cost.


6. Conceptual Summary: Which Command When?

Need Command Rationale
See everything Terraform knows terraform show Whole-state, read-only view (or a plan)
Inspect one resource deeply terraform state show ADDRESS Focused, per-resource state inspection
List all tracked resources terraform state list Discover resource addresses in state
Rename/move resources/modules terraform state mv Refactor mappings without downtime
Forget a resource but keep it alive terraform state rm Stop managing without deleting
Give other tools a clean interface terraform output / -json / -raw Official way to expose selected state data developer.

The underlying rationale is separation of concerns:

  • terraform showobservability of plans and state.
  • terraform stateprecise manipulation and inspection of state.
  • terraform outputcontrolled, stable API to state for humans and downstream systems.

Terraform Console in Practice: Your Interactive HCL Lab

Terraform console is an interactive interpreter where you can evaluate Terraform expressions, inspect state, and prototype logic before committing anything to code or infrastructure.


1. What terraform console actually is

At its core, terraform console is a REPL for Terraform’s expression language (HCL2).

  • It reads your configuration and current state from the configured backend, so you can query real values: var.*, local.*, resource.*, data.*.
  • It is read‑only with respect to infrastructure: it does not change resources or configuration, it only evaluates expressions against configuration/state or, if you have no state yet, against pure expressions and built‑ins.
  • It holds a lock on state while open, so other commands that need the state (plan/apply) will wait or fail until you exit.

Pedagogically, think of it as Terraform’s “maths lab”: you experiment with expressions and data structures in isolation before wiring them into modules.


2. Why you should care as a practitioner

You will use terraform console for three broad reasons:

  • Rapid feedback on expressions
    • Test for expressions, conditionals, complex locals, and functions like cidr*, jsonencode, jsondecode, file, etc., without running full plans.
  • Insight into “what Terraform thinks”
    • Inspect live values for resources, data sources, variables, and outputs as Terraform sees them in state, which is often where misunderstandings hide.
  • Debugging complex data structures
    • When for_each over nested maps/lists behaves oddly, you can print and transform the structures interactively to understand shape and keys before editing code.

This shortens the debug loop significantly on large stacks and reduces the risk of generating enormous, accidental plans.


3. Running the console and basic usage

In any initialized working directory:

terraform init    # if not already done
terraform console

You then get a prompt like:

> 1 + 2
3

> upper("auckland")
"AUCKLAND"

You can reference configuration components directly:

> var.cidr
"10.0.0.0/24"

> cidrnetmask(var.cidr)
"255.255.255.0"

> cidrhost(var.cidr, 10)
"10.0.0.10"

Inspecting resources and data sources:

> aws_s3_bucket.data
# prints the entire state object of that bucket (attributes, tags, region, etc.)

Terraform’s own tutorial demonstrates this pattern with an S3 bucket, using terraform console to print attributes like bucket, arn, region, ACLs and so on from state.

To exit:

> exit

Or press Ctrl+D / Ctrl+C.


4. Evaluating expressions: from simple to advanced

The console supports essentially any expression you can write in HCL: literals, operators, functions, for expressions, conditionals, etc.

Examples:

  • Lists and maps:

    > [for env in ["dev", "test", "prod"] : "env-${env}"]
    [
    "env-dev",
    "env-test",
    "env-prod",
    ]
    
    > { for k, v in { a = 1, b = 2, c = 3 } : k => v if v % 2 == 1 }
    {
    "a" = 1
    "c" = 3
    }
  • Filtering complex maps (example adapted from the docs):

    variable "apps" {
    type = map(any)
    default = {
      foo = { region = "us-east-1" }
      bar = { region = "eu-west-1" }
      baz = { region = "ap-south-1" }
    }
    }

    In the console:

    > var.apps.foo
    {
    "region" = "us-east-1"
    }
    
    > { for key, value in var.apps : key => value if value.region == "us-east-1" }
    {
    "foo" = {
      "region" = "us-east-1"
    }
    }
  • Testing network helpers:

    > cidrnetmask("172.16.0.0/12")
    "255.240.0.0"
    ```[1]

This is exactly how you should design locals and for_each expressions: prototype an expression in console, inspect the result, then paste into your module.


5. Inspecting state and outputs

Console is wired to your current backend and workspace.

  • Inspect an entire resource instance:

    > aws_s3_bucket.data
    # large object showing bucket name, ARN, tags, region, ACL, encryption, etc.

    The S3 tutorial shows this in detail, where the console prints attributes like bucket, bucket_domain_name, force_destroy, encryption configuration, tags, and more.

  • Build structured objects and validate them:

    > jsonencode({
      arn    = aws_s3_bucket.data.arn
      id     = aws_s3_bucket.data.id
      region = aws_s3_bucket.data.region
    })
    "\"{\\\"arn\\\":\\\"arn:aws:s3:::...\\\",\\\"id\\\":\\\"...\\\",\\\"region\\\":\\\"us-west-2\\\"}\""

The tutorial uses this pattern to design an output bucket_details, then later validates that terraform output -json bucket_details produces the exact desired JSON structure.

This is a powerful workflow: design your JSON structures interactively in console, then turn them into outputs or policy documents.


6. Using the console with plans (-plan)

By default, console evaluates expressions against the current state, which means values “known after apply” are not concrete yet.

You can ask console to evaluate against a fresh plan:

terraform console -plan

Now you can inspect “planned” values that do not exist in state yet, e.g. resources that are about to be created.

Rationale: this helps reason about the result of for_each, count, and complex expressions before touching real infrastructure. The docs do note that configurations which perform side effects during planning (for example via external data sources) will also do so in console -plan, so such patterns are discouraged.


7. Non‑interactive/scripting usage

You can pipe expressions into console from a script; only the last expression’s result is printed unless an error occurs.

Example from the reference:

echo 'split(",", "foo,bar,baz")' | terraform console

Output:

tolist([
  "foo",
  "bar",
  "baz",
])

This is extremely handy for:

  • CI checks that assert a particular expression evaluates to an expected structure.
  • One‑off debugging scripts that compute derived values from state (e.g. join tags, summarise regions) without adding permanent outputs.

8. A simple example

Let’s assemble a minimal, end‑to‑end example that you can run locally.

8.1. Configuration

Files:

variables.tf:

variable "cidr" {
  type    = string
  default = "10.0.0.0/24"
}

main.tf:

terraform {
  required_version = ">= 1.1.0"

  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }
  }
}

provider "random" {}

resource "random_password" "db" {
  length  = 16
  special = true
}

locals {
  subnet_ips = [
    for host in range(1, 5) :
    cidrhost(var.cidr, host)
  ]
}

output "db_password" {
  value     = random_password.db.result
  sensitive = true
}

output "subnet_ips" {
  value = local.subnet_ips
}

This uses standard providers and functions: random_password, cidrhost, range, and a for expression, all supported in Terraform 1.1+.

8.2. Apply once

terraform init
terraform apply -auto-approve

You now have state with random_password.db and all locals resolved.

8.3. Explore and validate with terraform console

Run:

terraform console

Try these expressions:

> var.cidr
"10.0.0.0/24"

> local.subnet_ips
[
  "10.0.0.1",
  "10.0.0.2",
  "10.0.0.3",
  "10.0.0.4",
]

> random_password.db.result
"R@nd0mP@ss..." # your value will be different

Validation steps:

  1. Confirm outputs match console:

    terraform output subnet_ips

    You should see the same list printed that you saw for local.subnet_ips in console. Both are derived from the same expression and state.

  2. Confirm password consistency:

    • terraform state show random_password.db will show the result field.
    • Compare that value with random_password.db.result printed in console; they must be identical for the same state snapshot.

If both checks pass, you have empirically validated that:

  • The console is looking at the same state as terraform state and terraform output.
  • Your locals and for expressions behave exactly as expected before you embed similar patterns into more complex modules.

9. Rationale and best‑practice use

From an engineering‑practice perspective, use terraform console as a standard part of your workflow:

  • Before adding non‑trivial expressions
    • Prototype them in console with realistic variable values; only once you’re happy paste them into locals or resource arguments.
  • When debugging bugs in production stacks
    • Inspect what Terraform actually has in state for a resource or data source, rather than inferring from code.

Used this way, console is not a “nice extra” but a core tool: it turns Terraform’s somewhat opaque expression runtime into something you can interrogate directly and safely.

Terraform Expressions and Functions

Terraform’s expression and function system is the core “thinking engine” behind your configurations: expressions describe what value an argument should have, and functions are reusable tools you invoke inside those expressions to compute values dynamically.


1. What Is an Expression in Terraform?

An expression is any piece of HCL that Terraform can evaluate to a concrete value: a string, number, bool, list, map, or object. You use expressions in almost every place on the right-hand side of arguments, in locals, count, for_each, dynamic blocks, and more.

Common expression forms:

  • Literals: "hello", 5, true, null, ["a", "b"], { env = "dev" }.
  • References: var.region, local.tags, aws_instance.app.id, module.vpc.vpc_id.
  • Operators: arithmetic (+ - * / %), comparisons (< > <= >=), equality (== !=), logical (&& || !).
  • Conditionals: condition ? value_if_true : value_if_false.
  • for expressions: [for s in var.subnets : upper(s)] to transform collections.
  • Splat expressions: aws_instance.app[*].id to project attributes out of collections.

Rationale: Terraform must stay declarative (you describe the desired state), but real infrastructure is not static; expressions give you a minimal “language” to derive values from other values without dropping into a general-purpose programming language.


2. What Is a Function?

A function is a built‑in helper you call inside expressions to transform or combine values. The syntax is a function name followed by comma‑separated arguments in parentheses, for example max(5, 12, 9). Functions always return a value, so they can appear anywhere a normal expression is allowed.

Key properties:

  • Terraform ships many built‑in functions (string, numeric, collection, IP/network, crypto, time, type conversion, etc.).
  • You cannot define your own functions in HCL; you only use built‑ins, plus any provider-defined functions a provider may export.
  • Provider-defined functions are namespaced like provider::<local-name>::function_name(...) when used.

Examples of useful built‑in functions:

  • String: upper("dev"), lower(), format(), join("-", ["app", "dev"]).
  • Numeric: max(5, 12, 9), min(), ceil(), floor().
  • Collection: length(var.subnets), merge(local.tags, local.extra_tags), flatten().

Rationale: Functions cover the common transformation needs (naming, list/map manipulation, math) so that your Terraform remains expressive but compact, and you avoid copy‑pasting “string‑mangling” logic everywhere.


3. How Expressions and Functions Work Together

Terraform’s model is expression‑centric: on the right‑hand side of almost every argument, you write an expression, and function calls are just one kind of expression. You freely compose references, operators, conditionals, for expressions, and functions, as long as the input and output types match.

Typical composition patterns:

  • Use references (var.*, local.*, resource attributes) as the base inputs.
  • Apply operators and conditional expressions to make decisions (var.env == "prod" ? 3 : 1).
  • Use for expressions and collection functions to reshape data ([for s in var.subnets : upper(s)]).
  • Use string functions to build consistent resource names (format("%s-%s", var.app, var.env)).

From a mental-model perspective, a good way to think about this is: “Everything dynamic lives in expressions; functions are building blocks inside those expressions.”


4. A Small Example

Below is a minimal Terraform configuration that showcases expressions and functions together, and that you can actually run to observe evaluation results.

Example configuration (main.tf)

terraform {
  required_version = ">= 1.6"
}

variable "environment" {
  type    = string
  default = "dev"
}

variable "app_servers" {
  type    = list(string)
  default = ["app-1", "app-2", "app-3"]
}

locals {
  # Expression: equality operator -> bool
  is_prod = var.environment == "prod"

  # Literal map and reference
  base_tags = {
    app         = "payments"
    environment = var.environment
  }

  # For expression + string function
  uppercased_servers = [for s in var.app_servers : upper(s)]

  # Merge and format functions to compute a name once
  common_tags = merge(
    local.base_tags,
    {
      name = format(
        "%s-%s-%02d",
        local.base_tags.app,
        local.base_tags.environment,
        length(var.app_servers)
      )
    }
  )
}

output "summary" {
  value = {
    is_prod            = local.is_prod
    uppercased_servers = local.uppercased_servers
    common_tags        = local.common_tags
  }
}

What this demonstrates conceptually:

  • Expressions:
    • var.environment == "prod" produces a bool for local.is_prod.
    • The map in local.base_tags uses both literals and references.
    • The locals block itself is a way to give names to intermediate expressions.
  • Functions:
    • upper(s) transforms each server name to uppercase inside a for expression.
    • length(var.app_servers) computes the number of servers.
    • format("%s-%s-%02d", ...) builds a stable name string.
    • merge(...) combines two maps into a single tag map.

Rationale: This pattern—variables + locals + expressions + functions—is exactly how you avoid repetition and keep a production Terraform codebase readable as it grows.


5. How to Validate This Example

Terraform provides an expression console and standard workflow commands to validate that your expressions and functions behave as expected before they affect real infrastructure.

Option A: Run the configuration

  1. Save the example as main.tf in an empty directory.
  2. Run:
    • terraform init to set up the working directory.
    • terraform apply -auto-approve to evaluate and show outputs.
  3. Observe the summary output:
    • is_prod should be false (with the default environment dev).
    • uppercased_servers should be ["APP-1", "APP-2", "APP-3"].
    • common_tags.name should be payments-dev-03.

To see how expressions react to different inputs, run again with a different environment:

terraform apply -auto-approve -var 'environment=prod'

Now is_prod will be true, and the computed name will switch to payments-prod-03, even though you haven’t changed any resource definitions.

Option B: Experiment interactively with terraform console

Terraform’s console lets you test expressions and functions on the fly.

From the same directory:

terraform console

Then try:

> 1 + 2 * 3
> var.environment == "prod"
> [for s in var.app_servers : upper(s)]
> merge({a = 1}, {b = 2})
> format("%s-%s", "app", var.environment)

You will see the evaluated results immediately, which is ideal for teaching yourself how a particular expression or function behaves before embedding it into a real module.


6. Summary Table: Expressions vs Functions

Aspect Expressions Functions
Purpose Describe how to compute a value. Provide reusable operations used inside expressions.
Examples var.env == "prod", [for s in xs : x.id]. length(var.subnets), join("-", local.tags).
Defined by Terraform language syntax. Terraform’s built‑in and provider-defined function library.
Customization Composed via locals, variables, and blocks. No user-defined functions in HCL; only built‑ins/providers.
Typical usage domain Conditionals, loops, references, constructing structures. String formatting, math, collection manipulation, conversion.

Terraform Format and Validation

In Terraform projects, format and validation are your first line of defence against messy code and avoidable runtime errors. Think of them as “style checking” and “sanity checking” for infrastructure as code.


Why Format and Validate at All?

Terraform configurations tend to grow into large, multi‑module codebases, often edited by several engineers at once. Without conventions and guards:

  • Small style differences accumulate into noisy diffs.
  • Subtle typos, type mismatches, or broken references sneak into main.
  • Misused modules cause surprises late in plan or even apply.

Formatting (terraform fmt) standardises how the code looks, while validation (terraform validate and variable validation) standardises what values are acceptable.


Formatting Terraform Code with terraform fmt

What terraform fmt Actually Does

terraform fmt rewrites your .tf files into Terraform’s canonical style.

It:

  • Normalises indentation and alignment.
  • Orders and spaces arguments consistently.
  • Applies a single canonical style across the project.

Typical usage:

# Fix formatting in the current directory
terraform fmt

# Recurse through modules and subfolders (what you want in a real repo)
terraform fmt -recursive

For CI or pre‑commit hooks you almost always want:

terraform fmt -check -recursive

This checks formatting, returns a non‑zero exit code if anything is off, but does not modify files. That makes it safe for pipelines.

Why This Matters Architecturally

  • Consistent formatting reduces cognitive load; you can scan resources quickly instead of re‑parsing everyone’s personal style.
  • Diffs stay focused on behaviour instead of whitespace and alignment.
  • A shared style is essential when modules are reused across teams and repos.

Treat terraform fmt like go fmt: it’s not a suggestion, it’s part of the toolchain.


Structural Validation with terraform validate

What terraform validate Checks

terraform validate performs a static analysis of your configuration for syntactic and internal consistency.

It verifies that:

  • HCL syntax is valid.
  • References to variables, locals, modules, resources, and data sources exist.
  • Types are consistent (for example you’re not passing a map where a string is expected).
  • Required attributes exist on resources and data blocks.

Basic usage:

terraform init     # required once before validate
terraform validate

If everything is fine you will see:

Success! The configuration is valid.

This does not contact cloud providers; it is a “compile‑time” check, not an integration test.terraformpilot+1

Why You Want It in Your Workflow

  • Catches simple but common mistakes (typos in attribute names, missing variables, wrong types) before plan.
  • Cheap enough to run on every commit and pull request.
  • Combined with fmt, it gives you a fast gate that keeps obviously broken code out of main.

In CI, a very standard pattern is:

terraform fmt -check -recursive
terraform init -backend=false   # or with backend depending on your setup
terraform validate

You can choose whether init uses the real backend or a local one; the key is that validate runs automatically.


Input Variable Validation: Types and Rules

Terraform also validates values going into your modules via input variables. There are three important layers.

1. Type Constraints

Every variable can and should declare a type: string, number, bool, complex types such as list(string) or object({ ... }). Terraform will reject values that do not conform.

Example:

variable "tags" {
  type = map(string)
}

Passing a list here fails fast, long before any resource is created.

2. Required vs Optional

  • Variables without a default are required; if the caller does not supply a value, Terraform fails at validation time.
  • Variables with a default are optional; they still participate in type and custom validation.

This lets you express what callers must always provide versus what can be inferred or defaulted.

3. Custom validation Blocks

Inside each variable block you can define one or more validation blocks.

Each block has:

  • condition: a boolean expression evaluated against the value.
  • error_message: a human‑readable message if the condition is false.

Example patterns from common practice include:

  • Membership checks with contains or regex.
  • Ranges and integer checks for numbers.
  • Multiple validation blocks to capture several independent rules.

The rationale here is strong: you make invalid states unrepresentable at the module boundary, rather than having to handle them deep inside resource logic.


Beyond Variables: Preconditions and Postconditions

Terraform also lets you validate assumptions around resources and data sources using precondition and postcondition blocks.

  • A precondition asserts something must be true before Terraform creates or updates the object (for example, an input computed from multiple variables is within bounds).
  • A postcondition asserts something must be true after the resource or data source is applied (for example, an attribute returned by the provider matches expectations).

Conceptually:

  • Variable validation guards inputs to modules.
  • Preconditions/postconditions guard behaviour of resources and data sources exposed by those modules.

For a team consuming your module, this is powerful: they get immediate, clear errors about violated invariants instead of mysterious provider failures later.


A Simple Example (Format + Validate + Variable Rules)

Below is a small, self‑contained configuration you can run locally to see formatting and validation in action.

Files

Create the files.

variables.tf:

variable "environment" {
  description = "Deployment environment."
     type        = string

  validation {
    condition     = contains(["dev", "test", "prod"], var.environment)
    error_message = "Environment must be one of: dev, test, prod."
  }
}

variable "app_name" {
  description = "Short application name used in resource naming."
  type        = string

  validation {
    condition     = can(regex("^[a-z0-9-]{3,20}$", var.app_name))
    error_message = "app_name must be 3-20 chars, lowercase letters, digits, and hyphens only."
  }
}

variable "instance_count" {
  description = "Number of instances to run."
  type        = number

  validation {
    condition     = var.instance_count >= 1 && var.instance_count <= 10
    error_message = "instance_count must be between 1 and 10."
  }

  validation {
    condition     = !(var.environment == "prod" && var.instance_count < 3)
    error_message = "In prod, instance_count must be at least 3."
  }
}

This demonstrates:

  • Type constraints on all inputs.
  • A small “enumeration” for environment.
  • A format rule enforced via regex on app_name.
  • Multiple independent validation rules on instance_count, including one that depends on environment.

main.tf:

terraform {
  required_version = ">= 1.5.0"
}

locals {
  app_tag = "${var.app_name}-${var.environment}"
}

output "example_tags" {
  value = {
    Environment = var.environment
    App         = var.app_name
    Count       = var.instance_count
    AppTag      = local.app_tag
  }
}

Step 1 – Format the Code

From inside the folder:

terraform fmt -recursive

Observe that Terraform will adjust spacing/indentation if you intentionally misalign something and run it again. This confirms fmt is active and working.

Step 2 – Initialize

terraform init

No providers are actually used here, but validate requires initialization.

Step 3 – Structural Validation

Run:

terraform validate

This checks syntax, references, and type soundness of the configuration itself.

If you see:

Success! The configuration is valid.

you know the configuration is structurally sound.

Step 4 – Test Variable Validation with plan and -var

To exercise your variable validation logic with specific values, use terraform plan with -var flags.

  1. Valid input:

    terraform plan -var="environment=dev" -var="app_name=demo-app" -var="instance_count=2"
    • Here -var is supported and your custom validation blocks are evaluated.
    • This should succeed, producing a plan (no resources, but the important part is that there are no validation errors).
  2. Invalid environment:

    terraform plan -var="environment=stage" -var="app_name=demo-app" -var="instance_count=2"

    Expect Terraform to fail with the custom environment error message from the validation block.

  3. Invalid app name:

    terraform plan -var="environment=dev" -var="app_name=Demo_App" -var="instance_count=2"

    You should see the regex‑based app_name error.

  4. Invalid prod count:

    terraform plan -var="environment=prod" -var="app_name=demo-app" -var="instance_count=1"

    Here, the environment is valid and the type is correct, but the cross‑rule on instance_count fails with your custom prod message.

Optional – Use *.tfvars Instead of -var

If you prefer files over command‑line flags, create dev.auto.tfvars:

environment    = "dev"
app_name       = "demo-app"
instance_count = 2

Then just run:

terraform plan

Terraform will automatically load *.auto.tfvars files and apply the same variable validations.


Recommended Pattern for Teams

Updated to reflect current behaviour:

  • Run terraform fmt -check -recursive and terraform validate in CI on every PR.
  • Use terraform plan (with -var or *.tfvars) to exercise and gate variable validations for concrete environments (dev, test, prod).
  • Enforce types and validation blocks on all externally visible variables, not just a handful.
  • Use preconditions and postconditions where module consumers must rely on specific guarantees from your resources.

From an engineering‑lead perspective, this gives you a clear division of responsibilities:

  • fmt → canonical style.
  • validate → structural soundness of the configuration.
  • plan (with variables) → semantic correctness of inputs and module contracts.

Terraform Block Types

Terraform configurations are built out of blocks. Understanding block types is critical because they define how you declare infrastructure, wire modules together, and control Terraform’s behavior.


1. The Anatomy of a Block

Every Terraform block has the same basic shape:

TYPE "label1" "label2" {
  argument_name = expression

  nested_block_type {
    # ...
  }
}

Key parts:

  • Type: The keyword at the start (resource, provider, variable, etc.). This tells Terraform what kind of thing you are defining.
  • Labels: Extra identifiers whose meaning depends on the block type.
    • Example: resource "aws_instance" "web"
    • Type: resource
    • Labels: "aws_instance" (resource type), "web" (local name)
  • Body: The { ... } section, which can contain:
    • Arguments: name = expression
    • Nested blocks: block_type { ... }

Rationale: The consistent shape makes the language predictable. Block type + labels define what the block is; the body defines how it behaves or is configured.


2. Core Top-Level Block Types

These blocks usually appear at the top level of your .tf files and together they define a module: its inputs, logic, and outputs.

2.1 terraform block

Configures Terraform itself:

  • Required providers and their versions.
  • Required Terraform version.
  • Backend configuration (usually via a nested backend block in terraform).

Example:

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

Rationale: Keeps tooling constraints explicit and version-pinned, so behavior is deterministic across environments and team members.


2.2 provider block

Configures how Terraform talks to an external API (AWS, Azure, GCP, Kubernetes, etc.):

provider "aws" {
  region = var.aws_region
}

Typical aspects:

  • Credentials and regions.
  • Aliases for multiple configurations (e.g., provider "aws" { alias = "eu" ... }).

Rationale: Providers are the “drivers” Terraform uses to translate configuration into real infrastructure; separating them lets you re-use the same module with different provider settings.


2.3 resource block

Declares infrastructure objects Terraform will create and manage.

resource "aws_s3_bucket" "this" {
  bucket = "${local.name}-bucket"
}

Structure:

  • Type label: the provider-specific resource type ("aws_s3_bucket").
  • Name label: a local identifier ("this", "web", "db", etc.).
  • Body: arguments and nested blocks that define the resource’s configuration.

Rationale: The resource block is the heart of Terraform; it expresses desired state. Every apply tries to reconcile actual infrastructure with what these blocks declare.


2.4 data block

Reads information about existing objects without creating anything.

data "aws_ami" "latest_amazon_linux" {
  most_recent = true

  filter {
    name   = "name"
    values = ["amazon-linux-2-*"]
  }

  owners = ["amazon"]
}

You reference it as data.aws_ami.latest_amazon_linux.id.

Rationale: Data sources decouple “lookup” from “creation”. You avoid hardcoding IDs/ARNs and can dynamically discover things like AMIs, VPC IDs, or roles.


2.5 variable block

Defines inputs to a module:

variable "aws_region" {
  type        = string
  description = "AWS region to deploy into"
  default     = "us-west-2"
}

Key fields:

  • type: basic or complex types (string, number, list, map, object, etc.).
  • default: makes a variable optional.
  • description: documentation for humans.

Rationale: Explicit inputs make modules reusable, testable, and self-documenting. They are your module’s API.


2.6 output block

Exposes values from a module:

output "bucket_name" {
  value       = aws_s3_bucket.this.bucket
  description = "Name of the S3 bucket created by this module."
}

Rationale: Outputs are your module’s return values, allowing composition: root modules can print values, and child modules can feed outputs into other modules or systems (e.g., CI/CD).


2.7 locals block

Defines computed values for use within a module:

locals {
  name_prefix = "demo"
  bucket_name = "${local.name_prefix}-bucket"
}

Notes:

  • You can have multiple locals blocks; Terraform merges them.
  • Access them via local.<name>.

Rationale: Locals centralize derived values and remove duplication. That keeps your configuration DRY and easier to refactor.


3. Nested Blocks vs Arguments

Within a block body you use two constructs:

  • Arguments: key = expression
    Example: bucket = "demo-bucket".

  • Nested blocks: block_type { ... }
    Example:

    resource "aws_instance" "web" {
    ami           = data.aws_ami.latest_amazon_linux.id
    instance_type = "t3.micro"
    
    network_interface {
      device_index = 0
      network_interface_id = aws_network_interface.web.id
    }
    }

Why have both?

  • Arguments are single values; they are the usual “settings”.
  • Nested blocks model structured, often repeatable configuration sections (e.g., ingress rules in security groups, network_interface, lifecycle, tag blocks in some providers).

Rationale: Using nested blocks for structured/repeated sections keeps complex resources readable and makes it clear which values logically belong together.


4. Meta-Arguments and Lifecycle Blocks

Some names inside a resource are meta-arguments understood by Terraform itself rather than by the provider:

Common meta-arguments:

  • depends_on: Add explicit dependencies when Terraform’s graph inference isn’t enough.
  • count: Create multiple instances of a resource using integer indexing.
  • for_each: Create multiple instances keyed by a map or set.
  • provider: Pin a resource to a specific provider configuration (e.g., aws.eu).
  • lifecycle: Special nested block that controls create/update/destroy behavior.

Example lifecycle:

resource "aws_s3_bucket" "this" {
  bucket = "${local.name}-bucket"

  lifecycle {
    prevent_destroy       = true
    ignore_changes        = [tags]
    create_before_destroy = true
  }
}

Rationale: Meta-arguments give you control over resource orchestration rather than definition. They let you express cardinality, ordering, and safety rules without resorting to hacks or external tooling.


5. Putting It All Together

Below is a small but coherent configuration that demonstrates the main block types and how they interact. You can drop this into an empty directory as main.tf.

terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

variable "aws_region" {
  type        = string
  description = "AWS region to deploy into (used by LocalStack as well)"
  default     = "us-east-1"
}

locals {
  project = "block-types-localstack-demo"
  bucket  = "${local.project}-bucket"
}

provider "aws" {
  region  = var.aws_region

  # Dummy credentials – LocalStack doesn’t actually validate them.
  access_key = "test"
  secret_key = "test"

  # Talk to LocalStack instead of AWS.
  s3_use_path_style           = true
  skip_credentials_validation = true
  skip_metadata_api_check     = true
  skip_requesting_account_id  = true

  endpoints {
    s3 = "http://localhost:4566"
    sts = "http://localhost:4566"
  }
}

# This data source will return the LocalStack “test” account (000000000000).
data "aws_caller_identity" "current" {}

resource "aws_s3_bucket" "this" {
  bucket = local.bucket

  tags = {
    Project = local.project
    Owner   = data.aws_caller_identity.current.account_id
  }

  lifecycle {
    prevent_destroy = true
  }
}

output "bucket_name" {
  value       = aws_s3_bucket.this.bucket
  description = "The name of the created S3 bucket."
}

output "account_id" {
  value       = data.aws_caller_identity.current.account_id
  description = "AWS (LocalStack) account ID used for this deployment."
}

What this example shows

  • terraform block: pins Terraform and the AWS provider.
  • variable: input for region.
  • locals: internal naming logic.
  • provider: AWS configuration.
  • data: a data source reading your current AWS identity.
  • resource: S3 bucket, including nested lifecycle and tags.
  • output: exposes bucket name and account ID.

How to Run and Validate with LocalStack

  1. Start LocalStack (for example, via Docker):

    docker run --rm -it -p 4566:4566 -p 4510-4559:4510-4559 localstack/localstack

    This exposes the LocalStack APIs on http://localhost:4566 as expected by the provider config.

  2. Initialize Terraform:

    terraform init
  3. Format and validate:

    terraform fmt -check
    terraform validate
  4. Plan and apply against LocalStack:

    terraform plan
    terraform apply

    Confirm with yes when prompted. Terraform will create the S3 bucket in LocalStack rather than AWS; the dummy credentials and endpoint mapping make this safe for local experimentation.

  5. Check outputs:

    terraform output
    terraform output bucket_name
    terraform output account_id
  6. Configure profile

    aws configure --profile localstack
  7. Verify in LocalStack (using AWS CLI configured to point to LocalStack):

    aws --endpoint-url http://localhost:4566 s3 ls --profile localstack

    You should see the bucket named in bucket_name. LocalStack typically uses test credentials and a default account ID of 000000000000.

  8. Destroy (noting prevent_destroy)

    Because of prevent_destroy = true, terraform destroy will refuse to delete the bucket. That’s intentional, to illustrate the lifecycle block. Remove prevent_destroy, run terraform apply again, then:

    terraform destroy

6. A Quick Comparison Table

To solidify the concepts, here is a concise comparison of key block types:

Block type Purpose Typical labels Commonly uses nested blocks
terraform Configure Terraform itself None required_providers, backend
provider Configure connection to an API Provider name (e.g., "aws") Occasionally provider-specific blocks
resource Declare managed infrastructure Resource type, local name lifecycle, provisioner, provider-specific
data Read existing infrastructure Data source type, local name Provider-specific nested blocks
variable Define module inputs Variable name None (just arguments)
output Expose module outputs Output name None (just arguments)
locals Define internal computed values None None (just arguments)
« Older posts