Bookshelf End to End

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

The earlier pages of this section each explain one idea in isolation. This page puts them in one repository and walks through the order in which a team would actually build it: state first, then modules, then two environments, then the safety net (checks, tests, linters, scanners), then the pipeline, and finally the two maintenance operations every long-lived repository needs, adopting an object that already exists and renaming something that is already deployed. It assumes the baseline of the rest of the section: Terraform 1.17 (OpenTofu 1.13 runs the same code, see the last sections), hashicorp/aws 6.x and kreuzwerker/docker 4.x. It is the last page of the reading path and it links back to the page that explains each part instead of repeating the explanation.

The scenario is Bookshelf: catalog-api, reviews-api and web containers, PostgreSQL, a covers bucket, a private network, and a secret shared by the services. Everything runs on a laptop with Docker and LocalStack, so no cloud account is needed. A later section shows what changes when the same repository targets a real AWS account. Bucket names, tokens, role ARNs and registries are placeholders. Commands and console output are illustrative and abbreviated: no Terraform binary was run to produce this page, so treat every transcript as the shape of the output, not as captured text.

Bookshelf infrastructure end to end: developers and GitHub Actions run the dev and prod root modules, which call the network, database, service and covers modules; resources land in Docker and LocalStack, with a dashed real AWS variant

What is built, and in which order

The order matters because each step needs the previous one:

Versions and tools used

Component Version Why

Terraform CLI

1.17 (needs 1.11 or later)

use_lockfile for the S3 backend (1.10 added it, experimental there), ephemeral resources (1.10) and write-only arguments (1.11). The code requires >= 1.11.

OpenTofu

1.13 (ephemeral resources and write-only arguments since 1.11)

Runs the same configuration; see the last sections.

hashicorp/aws

~> 6.0 (6.70.0 was current when this page was checked)

S3, Secrets Manager and, in the variant, RDS. Supports secret_string_wo and password_wo.

kreuzwerker/docker

~> 4.6 (4.6.0 was current)

Containers and networks for the local variant. The older module pages show ~> 3.0; use the 4.x constraint everywhere in a new repository.

hashicorp/random, hashicorp/http

~> 3.9, ~> 3.4

Ephemeral password and the health check.

LocalStack

current localstack/localstack image

Emulates S3 and Secrets Manager on port 4566. Coverage varies per service and edition.

TFLint, Checkov

current releases

Linting and policy scanning in CI.

The final repository layout

This is the tree the rest of the page builds. Keep it in mind while reading; every file shown below has a place in it.

bookshelf-infra/
├── .github/
│   └── workflows/
│       ├── terraform-checks.yml     # fmt, validate, test, TFLint, Checkov (no credentials)
│       └── terraform-deploy.yml     # plan and apply with OIDC (real-cloud variant)
├── .gitignore                       # .terraform/, *.tfstate*, *.tfplan, *.tfvars with secrets
├── .tflint.hcl
├── Makefile
├── bootstrap/
│   └── localstack.sh                # creates the state bucket (the only imperative step)
├── modules/
│   ├── network/                     # tiers cut from one CIDR block (from the module page)
│   ├── database/                    # PostgreSQL container, volume, healthcheck
│   ├── service/                     # N replicas of one image (catalog-api, reviews-api, web)
│   └── covers/                      # covers bucket: versioning, encryption, public access block
│       ├── versions.tf
│       ├── variables.tf
│       ├── main.tf
│       ├── outputs.tf
│       └── tests/covers.tftest.hcl
└── envs/
    ├── dev/
    │   ├── backend.tf               # backend "s3" {}  (empty: partial configuration)
    │   ├── dev.s3.tfbackend         # bucket, key, endpoint, use_lockfile
    │   ├── providers.tf
    │   ├── variables.tf
    │   ├── main.tf                  # module calls
    │   ├── secrets.tf               # ephemeral password + write-only secret version
    │   ├── checks.tf                # check block
    │   ├── imports.tf               # import block (adopt the hand-made bucket)
    │   ├── refactor.tf              # moved blocks
    │   ├── outputs.tf
    │   └── dev.tfvars
    └── prod/
        └── (same files, prod.s3.tfbackend and prod.tfvars)

Step 1: LocalStack and the state bucket

The state bucket is created by a script, not by Terraform. Terraform could create it, but then it would need a state to record it, which is the bootstrap loop; the script keeps the loop outside. The credentials are the conventional dummy values that LocalStack accepts; none of them is a secret.

#!/usr/bin/env bash
# bootstrap/localstack.sh -- run once per machine
set -euo pipefail

docker run --rm -d --name localstack -p 127.0.0.1:4566:4566 localstack/localstack

export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_DEFAULT_REGION=us-east-1
endpoint=http://localhost:4566

# wait until the edge port answers
until curl -fsS "$endpoint/_localstack/health" >/dev/null; do sleep 2; done

aws --endpoint-url "$endpoint" s3 mb s3://bookshelf-tfstate
aws --endpoint-url "$endpoint" s3api put-bucket-versioning \
  --bucket bookshelf-tfstate --versioning-configuration Status=Enabled

# a bucket somebody created by hand before Terraform existed (adopted in a later step)
aws --endpoint-url "$endpoint" s3 mb s3://bookshelf-dev-covers

Versioning on the state bucket lets you recover an earlier snapshot after a bad write. The last command creates the pre-existing object that the import step adopts later. Stopping the container (docker stop localstack) discards everything unless persistence is enabled, so rerun the script after a restart and remember that the state is gone too.

Step 2: the modules

Three modules already exist in Module Development and Publishing and are used here unchanged apart from the provider constraint. Their interfaces are what the root modules rely on:

Module Inputs the root module passes Outputs the root module reads

network

name, cidr_block, optional tiers

network_names (by tier), cidr_blocks

database

name, network_name, image_tag, optional password, resources

host, port, database_name, connection_url (sensitive)

service

name, image, networks, replicas, env, secret_env, ports, healthcheck

internal_urls

covers (new here)

name_prefix, force_destroy, versioned

bucket_name, bucket_arn

For a new repository set the Docker provider constraint in each module’s versions.tf to version = "~> 4.6" (the module page uses a wider >= 3.0 for compatibility). A module should declare what it needs, not configure it, so none of the four contains a provider block.

The covers module

Covers (book cover images) live in an S3-compatible bucket. The module is small on purpose, and it carries the rules that must hold in every environment: no public access, versioning on, server-side encryption on, and a name that follows the convention.

# modules/covers/versions.tf
terraform {
  required_version = ">= 1.11"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}
# modules/covers/variables.tf
variable "name_prefix" {
  type        = string
  description = "Prefix of the bucket name, for example bookshelf-dev. The module appends -covers."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,40}$", var.name_prefix))
    error_message = "The name_prefix must start with a letter and contain 3 to 41 lowercase letters, digits or hyphens."
  }
}

variable "force_destroy" {
  type        = bool
  description = "Allow destroying a bucket that still holds objects. Keep false in production."
  default     = false
}

variable "versioned" {
  type        = bool
  description = "Keep previous versions of every cover."
  default     = true
}
# modules/covers/main.tf
resource "aws_s3_bucket" "this" {
  # checkov:skip=CKV_AWS_144:Cross-region replication is not needed for re-creatable covers
  # checkov:skip=CKV_AWS_18:Access logging is configured at account level in this scenario
  bucket        = "${var.name_prefix}-covers"
  force_destroy = var.force_destroy
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_versioning" "this" {
  bucket = aws_s3_bucket.this.id

  versioning_configuration {
    status = var.versioned ? "Enabled" : "Suspended"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
  bucket = aws_s3_bucket.this.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
# modules/covers/outputs.tf
output "bucket_name" {
  description = "Name of the covers bucket."
  value       = aws_s3_bucket.this.bucket
}

output "bucket_arn" {
  description = "ARN of the covers bucket, for IAM policies."
  value       = aws_s3_bucket.this.arn
}

The two checkov:skip comments travel with the resource and appear in the scan as skipped checks with a reason, which is how a reviewer sees a deliberate exception. The longer discussion of the S3 resources is in Terraform on AWS.

Step 3: the backend, one partial file per environment

Backend blocks cannot use variables, so the block is empty and each environment supplies the settings with -backend-config (partial configuration). Two environments share the bucket and differ only in the key, which is the design the workspaces page recommends when separate accounts are not available.

# envs/dev/backend.tf
terraform {
  required_version = ">= 1.11"

  backend "s3" {}
}
# envs/dev/dev.s3.tfbackend
bucket = "bookshelf-tfstate"
key    = "bookshelf/dev/terraform.tfstate"
region = "us-east-1"

# LocalStack, not AWS: remove these lines for a real bucket
endpoints = {
  s3 = "http://localhost:4566"
}
use_path_style              = true
skip_credentials_validation = true
skip_requesting_account_id  = true
skip_metadata_api_check     = true
skip_region_validation      = true
skip_s3_checksum            = true

# native locking: a .tflock object next to the state object
use_lockfile = true

envs/prod/prod.s3.tfbackend is identical except for key = "bookshelf/prod/terraform.tfstate". The file holds no secret: the dummy credentials come from AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the environment. As the backends page notes, LocalStack’s documentation does not state that it enforces the .tflock object, so verify locking yourself in step 5 before relying on it.

Step 4: the dev root module

The root module is the only place that knows how the modules fit together and where the providers point. The same code serves prod; only the values change.

# envs/dev/providers.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
    http = {
      source  = "hashicorp/http"
      version = "~> 3.4"
    }
  }
}

provider "docker" {}

# LocalStack endpoints. For a real account these arguments are removed (see the variant section).
provider "aws" {
  region                      = var.region
  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

  endpoints {
    s3             = "http://localhost:4566"
    secretsmanager = "http://localhost:4566"
  }
}
# envs/dev/variables.tf
variable "environment" {
  type        = string
  description = "Environment name, used in resource names."

  validation {
    condition     = contains(["dev", "prod"], var.environment)
    error_message = "The environment must be dev or prod."
  }
}

variable "region" {
  type    = string
  default = "us-east-1"
}

variable "vpc_cidr" {
  type    = string
  default = "172.28.0.0/16"
}

variable "catalog_api_image" {
  type        = string
  description = "catalog-api image with an explicit tag. Placeholder registry."
  default     = "ghcr.io/example-org/bookshelf-catalog-api:1.4.2"
}

variable "reviews_api_image" {
  type        = string
  description = "reviews-api image with an explicit tag. Placeholder registry."
  default     = "ghcr.io/example-org/bookshelf-reviews-api:2.0.1"
}

variable "catalog_api_replicas" {
  type    = number
  default = 1
}

variable "web_port" {
  type        = number
  description = "Host port that publishes web."
  default     = 8080

  validation {
    condition     = var.web_port >= 1024 && var.web_port <= 65535
    error_message = "The web_port must be an unprivileged port, 1024 to 65535."
  }
}

variable "db_password" {
  type        = string
  description = "PostgreSQL superuser password. Supply it as TF_VAR_db_password; never commit it."
  sensitive   = true

  validation {
    condition     = length(var.db_password) >= 16
    error_message = "The db_password must be at least 16 characters long."
  }
}

variable "covers_force_destroy" {
  type    = bool
  default = true
}

variable "service_token_version" {
  type        = number
  description = "Increment to rotate the shared service token."
  default     = 1
}
# envs/dev/main.tf
module "network" {
  source = "../../modules/network"

  name       = "bookshelf-${var.environment}"
  cidr_block = var.vpc_cidr
}

module "database" {
  source = "../../modules/database"

  name         = "postgres-${var.environment}"
  network_name = module.network.network_names["backend"]
  image_tag    = "16"
  password     = var.db_password
}

module "catalog_api" {
  source = "../../modules/service"

  name     = "catalog-api-${var.environment}"
  image    = var.catalog_api_image
  replicas = var.catalog_api_replicas
  networks = [
    module.network.network_names["frontend"],
    module.network.network_names["backend"],
  ]

  env = {
    DATABASE_HOST = module.database.host
    DATABASE_PORT = tostring(module.database.port)
    DATABASE_NAME = module.database.database_name
    TOKEN_SECRET  = aws_secretsmanager_secret.service_token.name
    COVERS_BUCKET = module.covers.bucket_name
  }
  secret_env = {
    DATABASE_URL = module.database.connection_url
  }

  ports = [{ internal = 8080 }]
}

module "reviews_api" {
  source = "../../modules/service"

  name     = "reviews-api-${var.environment}"
  image    = var.reviews_api_image
  networks = [
    module.network.network_names["frontend"],
    module.network.network_names["backend"],
  ]

  env = {
    TOKEN_SECRET = aws_secretsmanager_secret.service_token.name
  }
  secret_env = {
    DATABASE_URL = module.database.connection_url
  }

  ports = [{ internal = 8081 }]
}

module "web" {
  source = "../../modules/service"

  name     = "web-${var.environment}"
  image    = "nginx:1.27"
  networks = [module.network.network_names["frontend"]]

  env = {
    CATALOG_API_URL = module.catalog_api.internal_urls["8080"]
    REVIEWS_API_URL = module.reviews_api.internal_urls["8081"]
  }

  ports = [{ internal = 80, external = var.web_port }]
}

module "covers" {
  source = "../../modules/covers"

  name_prefix   = "bookshelf-${var.environment}"
  force_destroy = var.covers_force_destroy
}
# envs/dev/outputs.tf
output "web_url" {
  value = "http://localhost:${var.web_port}"
}

output "covers_bucket" {
  value = module.covers.bucket_name
}

output "service_token_secret" {
  description = "Name of the Secrets Manager secret. The token itself is never an output."
  value       = aws_secretsmanager_secret.service_token.name
}
# envs/dev/dev.tfvars
environment          = "dev"
region               = "us-east-1"
vpc_cidr             = "172.28.0.0/16"
catalog_api_replicas = 1
web_port             = 8080
covers_force_destroy = true
# envs/prod/prod.tfvars -- the only differences from dev
environment          = "prod"
region               = "us-east-1"
vpc_cidr             = "172.29.0.0/16"
catalog_api_replicas = 2
web_port             = 8090
covers_force_destroy = false

Points worth noticing:

  • Every container and network name includes the environment, so the dev and prod root modules can run on the same Docker engine without colliding. The prod CIDR differs for the same reason (Docker networks cannot overlap).

  • module.catalog_api has two replicas in prod, so its published port stays internal; only web publishes a host port, which is the cross-variable rule of the service module at work.

  • There is no depends_on. References such as module.database.connection_url and module.network.network_names produce the dependency graph, as explained in Dependency Graph and Plan.

  • The password is not in any file. The variable is sensitive, has no default, and receives its value from TF_VAR_db_password (step 5). In the Docker variant the value still reaches the container’s env argument and is recorded in state, which is why the state bucket is private and versioned; the Docker provider page describes the limitation, and the real-cloud variant removes it.

  • prod.tfvars sets covers_force_destroy = false, so a terraform destroy of production cannot silently delete a bucket that still holds covers.

Secrets: ephemeral generation, write-only storage

The services need a shared token. It is generated ephemerally, written once to Secrets Manager through a write-only argument, and fetched by the containers at start-up with their own identity. Terraform never stores it, and it is not an output.

# envs/dev/secrets.tf
ephemeral "random_password" "service_token" {
  length  = 48
  special = false
}

resource "aws_secretsmanager_secret" "service_token" {
  name                    = "bookshelf/${var.environment}/service-token"
  recovery_window_in_days = 0 # allow immediate re-creation after destroy; use 7 to 30 for production
}

resource "aws_secretsmanager_secret_version" "service_token" {
  secret_id                = aws_secretsmanager_secret.service_token.id
  secret_string_wo         = ephemeral.random_password.service_token.result
  secret_string_wo_version = var.service_token_version
}

A write-only argument is sent to the provider and never saved, so Terraform cannot tell by itself that the value should change. The companion secret_string_wo_version is the trigger: raising service_token_version in the tfvars file makes the next apply generate and write a fresh token. Set recovery_window_in_days to a real window in production; zero is a convenience for a disposable local environment. This pattern, the alternatives and the places where an ephemeral value is not allowed are explained in Sensitive Data, Ephemeral Values and Write-Only Arguments. LocalStack does not document its support for write-only secret versions; if your emulator version rejects the call, test this file against a real account or keep the pattern and mock it in tests.

A check for the running system

A check block validates the world after the apply without failing it. Here it asks the published web port for a response. It warns, rather than errors, which is the right severity for a health probe that depends on container start-up.

# envs/dev/checks.tf
check "web_responds" {
  data "http" "web" {
    url = "http://localhost:${var.web_port}/"

    # Without this the first plan, before the container exists, would warn.
    depends_on = [module.web]
  }

  assert {
    condition     = data.http.web.status_code == 200
    error_message = "web answered ${data.http.web.status_code} on port ${var.web_port}."
  }
}

Variable validation blocks (the password length, the port range, the environment name) fail at plan time; the check fails softly after the apply. The page Lifecycle, Validation and Checks has the full comparison, including when to prefer a precondition.

Step 5: init, plan, apply and the lock test

The first run initializes the backend with the tfbackend file, supplies the password through the environment and uses an explicit variable file.

export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export TF_VAR_db_password="$(openssl rand -base64 24 | tr -d '/+=')"   # local only; keep it in a password manager
cd envs/dev

terraform init -backend-config=dev.s3.tfbackend
terraform fmt -check -recursive ../..
terraform validate
terraform plan -var-file=dev.tfvars -out=dev.tfplan
terraform apply dev.tfplan
$ terraform plan -var-file=dev.tfvars -out=dev.tfplan
# abbreviated and illustrative
  # module.network.docker_network.tier["backend"] will be created
  # module.database.docker_container.this will be created
  # module.catalog_api.docker_container.this["0"] will be created
  # module.covers.aws_s3_bucket.this will be created
  # aws_secretsmanager_secret.service_token will be created
  # aws_secretsmanager_secret_version.service_token will be created
...
Plan: 20 to add, 0 to change, 0 to destroy.

The resource count in the transcript is a placeholder; yours depends on the provider and module versions. The saved plan (dev.tfplan) can contain sensitive values, so it is ignored by Git. Repeat the commands in envs/prod with prod.s3.tfbackend and prod.tfvars and a different password.

Verifying that the lock works

Run two applies at the same time and confirm that the second is refused.

terraform apply -var-file=dev.tfvars -auto-approve &
sleep 2
terraform apply -var-file=dev.tfvars -auto-approve -lock-timeout=0s
wait
aws --endpoint-url http://localhost:4566 s3 ls s3://bookshelf-tfstate/bookshelf/dev/

When the lock is enforced, the second command reports that the state is locked and the listing briefly shows a terraform.tfstate.tflock object next to the state object. If both applies proceed, your LocalStack version does not implement the conditional writes that use_lockfile needs: keep the setting in the repository (it is correct for real S3) and treat the local environment as single-user. After an interrupted run, terraform force-unlock <LOCK_ID> releases the lock, but only when you are sure that nobody is still running.

Step 6: adopt an object that already exists

The bootstrap script created bookshelf-dev-covers by hand, as a team often does before it adopts Terraform. Terraform would otherwise try to create a bucket that already exists and fail. An import block makes the adoption reviewable: it is a plan item, not a command typed once on one laptop.

# envs/dev/imports.tf
import {
  to = module.covers.aws_s3_bucket.this
  id = "bookshelf-dev-covers"
}
$ terraform plan -var-file=dev.tfvars
# abbreviated and illustrative
  # module.covers.aws_s3_bucket.this will be imported
  # module.covers.aws_s3_bucket_versioning.this will be created
  # module.covers.aws_s3_bucket_public_access_block.this will be created
  # module.covers.aws_s3_bucket_server_side_encryption_configuration.this will be created

Plan: 1 to import, 3 to add, 0 to change, 0 to destroy.

The plan shows what the module will do to the bucket besides adopting it: the three configuration resources are new because the hand-made bucket had no versioning, encryption or public access block. That is the value of importing through the module, since the bucket is brought up to the standard in the same review. Once the apply finishes, the import block is finished work and can stay (it is idempotent) or be removed in a later change. The import block belongs to the root module, and its to address reaches into the module; the import page lists the cases, including -generate-config-out for objects with many arguments.

Step 7: a rename that must not recreate anything

During review the name reviews for the second API is judged too vague, and the module call is renamed to reviews_api. Without help, Terraform sees one module disappear and another appear, and plans to destroy and recreate its containers. A moved block records the rename instead.

# envs/dev/refactor.tf -- history of address changes; keep until every environment has applied them
moved {
  from = module.reviews
  to   = module.reviews_api
}
$ terraform plan -var-file=dev.tfvars
# abbreviated and illustrative
  # module.reviews has moved to module.reviews_api
...
Plan: 0 to add, 0 to change, 0 to destroy.

Copy the same moved block to envs/prod/refactor.tf: each environment has its own state and must apply the move separately. A module test or a CI step that asserts the plan contains no delete or replace actions turns this into a guard rail. Retiring an object without destroying it uses the sibling removed block with destroy = false; both are covered in Refactoring: moved and removed.

Step 8: tests, linting and scanning

Cheap checks run first and need no credentials: format, validate, unit tests with a mocked provider, TFLint and Checkov.

A unit test with a mocked AWS provider

The covers module is tested without LocalStack or any account: the provider is mocked, and a second run proves that an invalid prefix is rejected.

# modules/covers/tests/covers.tftest.hcl
mock_provider "aws" {}

variables {
  name_prefix = "bookshelf-test"
}

run "bucket_is_private_and_versioned" {
  command = apply

  assert {
    condition     = aws_s3_bucket.this.bucket == "bookshelf-test-covers"
    error_message = "The bucket must be named after the prefix."
  }

  assert {
    condition     = aws_s3_bucket_public_access_block.this.block_public_policy
    error_message = "Public bucket policies must be blocked."
  }

  assert {
    condition     = aws_s3_bucket_versioning.this.versioning_configuration[0].status == "Enabled"
    error_message = "Versioning must be enabled by default."
  }
}

run "invalid_prefix_is_rejected" {
  command = plan

  variables {
    name_prefix = "Bad Prefix"
  }

  expect_failures = [var.name_prefix]
}

Run it from the module directory with terraform init -backend=false and terraform test. The network module has the plan-only test shown in the module page; the database and service modules would mock the docker provider in the same way, as in the testing page.

TFLint

# .tflint.hcl
tflint {
  required_version = ">= 0.50"
}

config {
  call_module_type = "local"
}

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "aws" {
  enabled = true
  version = "0.50.0"
  source  = "github.com/terraform-linters/tflint-ruleset-aws"
}

rule "terraform_documented_variables" {
  enabled = true
}

rule "terraform_documented_outputs" {
  enabled = true
}

rule "terraform_naming_convention" {
  enabled = true
}

The AWS ruleset version is a pinned example from the scanning page; update it from the ruleset’s releases.

One entry point for developers and CI

# Makefile
ENVS := dev prod

.PHONY: check fmt validate test lint scan
check: fmt validate test lint scan

fmt:
	terraform fmt -check -recursive -diff

validate:
	for e in $(ENVS); do \
	  terraform -chdir=envs/$$e init -backend=false -input=false && \
	  terraform -chdir=envs/$$e validate || exit 1; \
	done

test:
	for m in network database service covers; do \
	  terraform -chdir=modules/$$m init -backend=false -input=false && \
	  terraform -chdir=modules/$$m test || exit 1; \
	done

lint:
	tflint --init
	tflint --recursive --format compact

scan:
	checkov -d . --framework terraform --quiet --compact

Because the same targets run on a laptop and in CI, a pipeline failure can always be reproduced with make check. On an existing repository with many findings, adopt Checkov with --create-baseline first and fail only on new findings, as the scanning page describes. Findings that are deliberate are skipped in code, with a reason, as in the covers module.

Step 9: GitHub Actions with OIDC

Two workflows split read from write. The checks workflow needs no credentials and runs for every pull request, including forks. The deploy workflow assumes an AWS role through OpenID Connect, so there is no stored access key. It targets the real-cloud variant of the next section, because GitHub-hosted runners cannot reach the LocalStack on your laptop (the same workflow can run against a LocalStack service container if you want a full local rehearsal in CI).

# .github/workflows/terraform-checks.yml
name: terraform-checks

on:
  pull_request:
    paths:
      - "modules/**"
      - "envs/**"
      - ".github/workflows/terraform-*.yml"

permissions:
  contents: read

env:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7

      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_wrapper: false

      - uses: terraform-linters/setup-tflint@v6

      - name: install Checkov
        run: pip install checkov==3.3.30

      - name: make check
        run: make check
# .github/workflows/terraform-deploy.yml
name: terraform-deploy

on:
  push:
    branches: [main]
    paths:
      - "modules/**"
      - "envs/**"

permissions:
  contents: read

env:
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"

jobs:
  dev:
    runs-on: ubuntu-latest
    environment: dev
    concurrency:
      group: terraform-dev
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write          # request the GitHub OIDC token
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}      # an environment variable of "dev"
          aws-region: ${{ vars.AWS_REGION }}
      - name: apply dev
        working-directory: envs/dev
        run: |
          terraform init -backend-config=dev.aws.s3.tfbackend
          terraform apply -auto-approve -lock-timeout=5m -var-file=dev.aws.tfvars
        env:
          TF_VAR_db_password: ${{ secrets.DEV_DB_PASSWORD }}

  prod:
    needs: dev
    runs-on: ubuntu-latest
    environment: production        # required reviewers are enforced here
    concurrency:
      group: terraform-prod
      cancel-in-progress: false
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v7
      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_wrapper: false
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}      # an environment variable of "production"
          aws-region: ${{ vars.AWS_REGION }}
      - name: plan prod
        working-directory: envs/prod
        run: |
          terraform init -backend-config=prod.aws.s3.tfbackend
          terraform plan -lock-timeout=5m -var-file=prod.aws.tfvars -out=tfplan
      - name: apply prod
        working-directory: envs/prod
        run: terraform apply -lock-timeout=5m tfplan

Notes on the pipeline:

  • The majors in the workflow (actions/checkout v7, hashicorp/setup-terraform v4, terraform-linters/setup-tflint v6, aws-actions/configure-aws-credentials v6) are the ones the CI page verified. The hashicorp/setup-terraform README shows v4, not the v3 mentioned in the original issue for this section.

  • id-token: write is what lets the job request a GitHub OIDC token. The role’s trust policy must accept only this repository and the matching environment as subject (repo:ORG/REPO:environment:production), as in the Terraform snippet of the CI page. The role ARN is an identifier, not a secret, and lives in an environment variable.

  • The database password still comes from a repository environment secret in this simplified file. Where the cloud supports it, replace it with a write-only argument and a generated password (next section) so that even that secret disappears.

  • The production job waits for the production environment’s required reviewers. The CI page shows the stricter variant that saves the reviewed plan as an artifact and applies exactly that file; use it for production.

Step 10: the real-cloud variant

The structure does not change. What changes is below the module interfaces and in the provider and backend files.

Piece Local (this page) Real AWS

Backend file

dev.s3.tfbackend with endpoints, use_path_style and skip_*

dev.aws.s3.tfbackend with bucket, key, region, encrypt = true, use_lockfile = true only

aws provider

dummy keys, endpoints block, skip_*

no credentials in code: OIDC in CI, SSO or a profile on a laptop; default_tags

network

Docker networks cut with cidrsubnet

terraform-aws-modules/vpc/aws or an equivalent module with the same outputs shape

database

PostgreSQL container, password from TF_VAR_db_password

aws_db_instance with a write-only password

service

Docker containers

ECS services on Fargate behind a load balancer

covers

unchanged

unchanged

Secrets

LocalStack Secrets Manager

AWS Secrets Manager, same secrets.tf

# envs/prod/prod.aws.s3.tfbackend
bucket       = "bookshelf-tfstate-example"   # placeholder; created by a separate bootstrap
key          = "bookshelf/prod/terraform.tfstate"
region       = "eu-west-1"
encrypt      = true
use_lockfile = true
# modules/database-aws/main.tf (replaces modules/database in the cloud variant)
variable "password_version" {
  type        = number
  description = "Increment to rotate the master password."
  default     = 1
}

ephemeral "random_password" "db" {
  length  = 32
  special = false
}

resource "aws_db_instance" "this" {
  identifier          = var.name
  engine              = "postgres"
  instance_class      = var.instance_class
  allocated_storage   = 20
  storage_encrypted   = true
  username            = "bookshelf_admin"
  skip_final_snapshot = var.environment != "prod"

  password_wo         = ephemeral.random_password.db.result
  password_wo_version = var.password_version

  db_subnet_group_name   = var.db_subnet_group_name
  vpc_security_group_ids = var.security_group_ids
}

An alternative that avoids handling the password at all is manage_master_user_password = true, which lets RDS keep the master password in Secrets Manager; the AWS page shows it. Whichever is chosen, the database module keeps the same outputs, so the root module changes only its source and the arguments that differ. The full AWS baseline (VPC, RDS, ElastiCache, IAM roles, ECS) is in Terraform on AWS; the identity side of the OIDC role is in AWS Identity and Access Management and AWS Infrastructure as Code. Azure and Google Cloud follow the same recipe with their own state backends and federation: see Terraform on Azure and Terraform on Google Cloud.

The Docker provider page also shows how the same application is expressed as Kubernetes objects and Helm releases, for the teams that run Bookshelf on a cluster: Docker, Kubernetes and Helm Providers.

Cleaning up

Destroy in the reverse order of creation, production first only if you really mean it. The covers bucket in dev has force_destroy = true, so it is removed with its contents; prod has false and refuses until the bucket is empty.

cd envs/dev
terraform destroy -var-file=dev.tfvars
cd ../..
docker stop localstack          # the state bucket and the state vanish with the container

Using OpenTofu instead

The configuration uses only features that exist in both tools at the versions shown: partial backend configuration, S3 use_lockfile, ephemeral resources and write-only arguments (OpenTofu 1.11 and later), check blocks, import, moved and mock providers. Replace terraform with tofu in the Makefile and the workflows, install it with opentofu/setup-opentofu (v2 as verified in the CI page) and run the checks as a matrix to prove the compatibility, as that page shows. OpenTofu’s own state encryption and early evaluation are not used here; adopting them would make the repository OpenTofu-only. See OpenTofu for the differences.

Pitfalls collected from the build

  • Applying prod from a laptop. The pipeline should be the only identity that can write production state. Give laptops read-only roles.

  • Sharing one state key. Dev and prod must use different keys (and ideally different buckets and accounts). A wrong key makes plan propose destroying the other environment.

  • Forgetting the password variable. An unset TF_VAR_db_password prompts in a terminal and fails in CI with TF_INPUT=false; that failure is deliberate.

  • Committing plan files or tfvars with secrets. Saved plans can contain sensitive values. Keep *.tfplan and secret tfvars out of Git.

  • Expecting a write-only argument to track changes. Without a version bump the secret never rotates.

  • Moving without moved. Renaming a module call or resource without a moved block plans a destroy and create.

  • Trusting a local lock. Verify locking on every backend you use before relying on it, as in step 5.

  • Emulator settings in production code. Keep endpoints, skip_* and dummy keys out of the real provider and backend files; the table in the variant section lists what to remove.

Limits of the local setup

  • LocalStack does not document its enforcement of the .tflock lock object or its support for write-only Secrets Manager versions, so test both on the LocalStack version you run before relying on them.

  • Resource counts and console output in the transcripts are abbreviated; your runs print more detail.

  • The TFLint AWS ruleset version and the Checkov pin are examples; pin the versions current when you adopt them.