IaC AWS

Terraform Module: AWS App Runner — ship containers to a managed URL without an ALB or cluster

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for AWS App Runner: image- or source-based services, auto scaling configurations, VPC egress connectors, and an IAM access role wired up for production. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

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

module "app_runner" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-app-runner?ref=v1.0.0"

  service_name     = "..."  # Name of the service; also names the scaling config, acc…
  image_identifier = "..."  # Container image URI (ECR private or ECR Public).
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

AWS App Runner is a fully managed service that takes a container image (or a source-code repository) and runs it behind an HTTPS endpoint, with load balancing, TLS termination, request-driven auto scaling, and health checks all handled for you. There is no ALB to provision, no ECS cluster to size, no target group to register, and no certificate to renew — you hand App Runner an image URI and a port, and you get back a *.awsapprunner.com URL serving traffic.

The single aws_apprunner_service resource is deceptively small, but a production-ready service is never just the service. You almost always need an auto scaling configuration (so the service does not silently default to a 1–25 instance band you never reviewed), an IAM access role so App Runner can pull from a private ECR repository, and frequently a VPC connector so the container can reach an RDS database or an internal service that lives in private subnets. Wiring those four resources together by hand in every stack is repetitive and easy to get subtly wrong (for example, forgetting that the access role is only needed for ECR-private images, or that the instance role — a different role — is what your application code uses to call AWS APIs).

This module wraps all of that behind a clean variable interface: pass an image, optionally a list of egress subnets, and a few scaling knobs, and the module creates the auto scaling configuration, the access role with a least-privilege ECR policy, the optional VPC connector, and the service itself — fully cross-referenced — and returns the service URL and ARN as outputs.

When to use it

Reach for ECS/Fargate or EKS instead when you need sidecars, gRPC streaming with long-lived connections beyond App Runner’s limits, GPU workloads, fine-grained networking (multiple ENIs, custom load-balancer rules), or daemon/batch workloads — App Runner is intentionally opinionated toward request/response web services.

Module structure

terraform-module-aws-app-runner/
├── versions.tf
├── main.tf
├── variables.tf
└── outputs.tf
# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}
# main.tf

locals {
  # App Runner needs an access role only when pulling a *private* ECR image.
  needs_access_role = var.image_repository_type == "ECR"

  tags = merge(
    {
      "Name"      = var.service_name
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-app-runner"
    },
    var.tags,
  )
}

# ---------------------------------------------------------------------------
# Auto scaling configuration — pinned so the service never silently inherits
# an unreviewed default band. App Runner versions these immutably.
# ---------------------------------------------------------------------------
resource "aws_apprunner_auto_scaling_configuration_version" "this" {
  auto_scaling_configuration_name = var.service_name
  max_concurrency                 = var.max_concurrency
  min_size                        = var.min_size
  max_size                        = var.max_size

  tags = local.tags
}

# ---------------------------------------------------------------------------
# Access role — lets the App Runner control plane pull from private ECR.
# This is distinct from the instance role used by your running code.
# ---------------------------------------------------------------------------
data "aws_iam_policy_document" "access_assume" {
  count = local.needs_access_role ? 1 : 0

  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["build.apprunner.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "access" {
  count = local.needs_access_role ? 1 : 0

  name                 = "${var.service_name}-apprunner-access"
  assume_role_policy   = data.aws_iam_policy_document.access_assume[0].json
  permissions_boundary = var.permissions_boundary_arn
  tags                 = local.tags
}

resource "aws_iam_role_policy_attachment" "access_ecr" {
  count = local.needs_access_role ? 1 : 0

  role       = aws_iam_role.access[0].name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}

# ---------------------------------------------------------------------------
# Optional VPC connector — egress into private subnets (RDS, internal NLB...).
# ---------------------------------------------------------------------------
resource "aws_apprunner_vpc_connector" "this" {
  count = length(var.egress_subnets) > 0 ? 1 : 0

  vpc_connector_name = var.service_name
  subnets            = var.egress_subnets
  security_groups    = var.egress_security_groups

  tags = local.tags
}

# ---------------------------------------------------------------------------
# The service itself.
# ---------------------------------------------------------------------------
resource "aws_apprunner_service" "this" {
  service_name = var.service_name

  source_configuration {
    auto_deployments_enabled = var.auto_deployments_enabled

    # Access role is only attached for private ECR pulls.
    dynamic "authentication_configuration" {
      for_each = local.needs_access_role ? [1] : []
      content {
        access_role_arn = aws_iam_role.access[0].arn
      }
    }

    image_repository {
      image_identifier      = var.image_identifier
      image_repository_type = var.image_repository_type

      image_configuration {
        port                          = tostring(var.port)
        runtime_environment_variables = var.environment_variables
        runtime_environment_secrets   = var.environment_secrets
        start_command                 = var.start_command
      }
    }
  }

  instance_configuration {
    cpu               = var.cpu
    memory            = var.memory
    instance_role_arn = var.instance_role_arn
  }

  health_check_configuration {
    protocol            = var.health_check_protocol
    path                = var.health_check_protocol == "HTTP" ? var.health_check_path : null
    interval            = var.health_check_interval
    timeout             = var.health_check_timeout
    healthy_threshold   = var.health_check_healthy_threshold
    unhealthy_threshold = var.health_check_unhealthy_threshold
  }

  network_configuration {
    ingress_configuration {
      is_publicly_accessible = var.is_publicly_accessible
    }

    egress_configuration {
      egress_type       = length(var.egress_subnets) > 0 ? "VPC" : "DEFAULT"
      vpc_connector_arn = length(var.egress_subnets) > 0 ? aws_apprunner_vpc_connector.this[0].arn : null
    }
  }

  auto_scaling_configuration_arn = aws_apprunner_auto_scaling_configuration_version.this.arn

  dynamic "encryption_configuration" {
    for_each = var.kms_key_arn != null ? [1] : []
    content {
      kms_key = var.kms_key_arn
    }
  }

  tags = local.tags
}
# variables.tf

variable "service_name" {
  description = "Name of the App Runner service. Also used to name the auto scaling config, access role, and VPC connector."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$", var.service_name))
    error_message = "service_name must be 3-40 chars, start alphanumeric, and contain only letters, numbers, hyphens, or underscores."
  }
}

variable "image_identifier" {
  description = "Container image to run. For ECR: <acct>.dkr.ecr.<region>.amazonaws.com/<repo>:<tag>. For ECR Public: public.ecr.aws/<repo>:<tag>."
  type        = string
}

variable "image_repository_type" {
  description = "Where the image lives: ECR (private, needs access role) or ECR_PUBLIC."
  type        = string
  default     = "ECR"

  validation {
    condition     = contains(["ECR", "ECR_PUBLIC"], var.image_repository_type)
    error_message = "image_repository_type must be either ECR or ECR_PUBLIC."
  }
}

variable "port" {
  description = "Port the container listens on for HTTP traffic."
  type        = number
  default     = 8080

  validation {
    condition     = var.port > 0 && var.port <= 65535
    error_message = "port must be between 1 and 65535."
  }
}

variable "cpu" {
  description = "vCPU for each instance. Valid App Runner values: 256, 512, 1024, 2048, 4096 (expressed as e.g. \"1024\" or \"1 vCPU\")."
  type        = string
  default     = "1024"

  validation {
    condition     = contains(["256", "512", "1024", "2048", "4096"], var.cpu)
    error_message = "cpu must be one of 256, 512, 1024, 2048, 4096."
  }
}

variable "memory" {
  description = "Memory (MB) for each instance. Valid: 512, 1024, 2048, 3072, 4096, 6144, 8192, 10240, 12288."
  type        = string
  default     = "2048"

  validation {
    condition     = contains(["512", "1024", "2048", "3072", "4096", "6144", "8192", "10240", "12288"], var.memory)
    error_message = "memory must be one of 512, 1024, 2048, 3072, 4096, 6144, 8192, 10240, 12288."
  }
}

variable "min_size" {
  description = "Minimum number of provisioned instances App Runner keeps warm."
  type        = number
  default     = 1

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

variable "max_size" {
  description = "Maximum number of instances App Runner will scale out to."
  type        = number
  default     = 5

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

variable "max_concurrency" {
  description = "Concurrent requests per instance before App Runner scales out."
  type        = number
  default     = 100

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

variable "auto_deployments_enabled" {
  description = "Redeploy automatically when a new image tag is pushed to ECR. Keep false in prod if you promote via pipeline."
  type        = bool
  default     = false
}

variable "start_command" {
  description = "Optional command to override the image's default entrypoint. Null uses the image CMD."
  type        = string
  default     = null
}

variable "environment_variables" {
  description = "Plain (non-secret) environment variables passed to the container."
  type        = map(string)
  default     = {}
}

variable "environment_secrets" {
  description = "Map of env var name to a Secrets Manager / SSM Parameter Store ARN. Injected at runtime, never stored in plan."
  type        = map(string)
  default     = {}
}

variable "instance_role_arn" {
  description = "IAM role ARN assumed by the running container to call AWS APIs (DynamoDB, S3...). Distinct from the access role. Null to omit."
  type        = string
  default     = null
}

variable "is_publicly_accessible" {
  description = "Whether the service has a public endpoint. Set false for private (VPC ingress) services fronted by an internal load balancer."
  type        = bool
  default     = true
}

variable "egress_subnets" {
  description = "Private subnet IDs for a VPC connector. If non-empty, the service egresses through your VPC instead of the public internet."
  type        = list(string)
  default     = []
}

variable "egress_security_groups" {
  description = "Security group IDs attached to the VPC connector ENIs. Required when egress_subnets is set."
  type        = list(string)
  default     = []
}

variable "health_check_protocol" {
  description = "Health check protocol: TCP or HTTP. HTTP also checks health_check_path."
  type        = string
  default     = "HTTP"

  validation {
    condition     = contains(["TCP", "HTTP"], var.health_check_protocol)
    error_message = "health_check_protocol must be TCP or HTTP."
  }
}

variable "health_check_path" {
  description = "Path probed when health_check_protocol is HTTP (e.g. /healthz)."
  type        = string
  default     = "/"
}

variable "health_check_interval" {
  description = "Seconds between health checks (1-20)."
  type        = number
  default     = 10

  validation {
    condition     = var.health_check_interval >= 1 && var.health_check_interval <= 20
    error_message = "health_check_interval must be between 1 and 20 seconds."
  }
}

variable "health_check_timeout" {
  description = "Seconds to wait for a health check response (1-20)."
  type        = number
  default     = 5

  validation {
    condition     = var.health_check_timeout >= 1 && var.health_check_timeout <= 20
    error_message = "health_check_timeout must be between 1 and 20 seconds."
  }
}

variable "health_check_healthy_threshold" {
  description = "Consecutive successful checks before an instance is considered healthy (1-20)."
  type        = number
  default     = 1

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

variable "health_check_unhealthy_threshold" {
  description = "Consecutive failed checks before an instance is considered unhealthy (1-20)."
  type        = number
  default     = 5

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

variable "kms_key_arn" {
  description = "Customer-managed KMS key ARN to encrypt App Runner's copy of your source image and logs. Null uses an AWS-owned key."
  type        = string
  default     = null
}

variable "permissions_boundary_arn" {
  description = "Optional IAM permissions boundary applied to the generated access role."
  type        = string
  default     = null
}

variable "tags" {
  description = "Additional tags merged onto every resource the module creates."
  type        = map(string)
  default     = {}
}
# outputs.tf

output "service_id" {
  description = "The App Runner service ID."
  value       = aws_apprunner_service.this.service_id
}

output "service_arn" {
  description = "ARN of the App Runner service (use to grant IAM permissions or wire alarms)."
  value       = aws_apprunner_service.this.arn
}

output "service_name" {
  description = "Name of the App Runner service."
  value       = aws_apprunner_service.this.service_name
}

output "service_url" {
  description = "The default *.awsapprunner.com domain serving the service over HTTPS."
  value       = aws_apprunner_service.this.service_url
}

output "status" {
  description = "Current status of the service (e.g. RUNNING, OPERATION_IN_PROGRESS)."
  value       = aws_apprunner_service.this.status
}

output "auto_scaling_configuration_arn" {
  description = "ARN of the versioned auto scaling configuration attached to the service."
  value       = aws_apprunner_auto_scaling_configuration_version.this.arn
}

output "vpc_connector_arn" {
  description = "ARN of the VPC connector, or null when the service egresses to the public internet."
  value       = try(aws_apprunner_vpc_connector.this[0].arn, null)
}

output "access_role_arn" {
  description = "ARN of the generated ECR access role, or null for ECR_PUBLIC images."
  value       = try(aws_iam_role.access[0].arn, null)
}

How to use it

module "app_runner" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-app-runner?ref=v1.0.0"

  service_name     = "orders-api-prod"
  image_identifier = "${aws_ecr_repository.orders.repository_url}:${var.image_tag}"

  cpu    = "1024"
  memory = "2048"
  port   = 8080

  # Scale on concurrent requests; keep two warm to avoid cold starts.
  min_size        = 2
  max_size        = 10
  max_concurrency = 80

  # App code reaches RDS in private subnets via a VPC connector.
  egress_subnets         = module.network.private_subnet_ids
  egress_security_groups = [aws_security_group.orders_egress.id]

  # Runtime config + secrets pulled from Secrets Manager (never in plan).
  instance_role_arn = aws_iam_role.orders_runtime.arn
  environment_variables = {
    LOG_LEVEL = "info"
    APP_ENV   = "production"
  }
  environment_secrets = {
    DATABASE_URL = aws_secretsmanager_secret.orders_db_url.arn
  }

  health_check_protocol = "HTTP"
  health_check_path     = "/healthz"

  kms_key_arn = aws_kms_key.apprunner.arn

  tags = {
    Team        = "commerce"
    Environment = "prod"
    CostCenter  = "cc-4471"
  }
}

# Downstream: point a friendly DNS record at the App Runner URL.
resource "aws_route53_record" "orders_api" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "orders.kloudvin.com"
  type    = "CNAME"
  ttl     = 300
  records = [module.app_runner.service_url]
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/app_runner/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-app-runner?ref=v1.0.0"
}

inputs = {
  service_name = "..."
  image_identifier = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/app_runner && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
service_name string yes Name of the service; also names the scaling config, access role, and VPC connector.
image_identifier string yes Container image URI (ECR private or ECR Public).
image_repository_type string "ECR" no ECR (private, adds access role) or ECR_PUBLIC.
port number 8080 no Container listening port (1–65535).
cpu string "1024" no vCPU per instance: 256/512/1024/2048/4096.
memory string "2048" no Memory MB per instance (512–12288).
min_size number 1 no Minimum warm instances (1–25).
max_size number 5 no Maximum instances on scale-out (1–25).
max_concurrency number 100 no Concurrent requests per instance before scaling (1–200).
auto_deployments_enabled bool false no Auto-redeploy on new ECR push.
start_command string null no Override the image entrypoint.
environment_variables map(string) {} no Plain runtime env vars.
environment_secrets map(string) {} no Env var name → Secrets Manager/SSM ARN.
instance_role_arn string null no IAM role the running container assumes for AWS API calls.
is_publicly_accessible bool true no Public endpoint vs private VPC ingress.
egress_subnets list(string) [] no Private subnets for a VPC connector (non-empty enables VPC egress).
egress_security_groups list(string) [] no Security groups on the VPC connector ENIs.
health_check_protocol string "HTTP" no TCP or HTTP.
health_check_path string "/" no HTTP health probe path.
health_check_interval number 10 no Seconds between checks (1–20).
health_check_timeout number 5 no Health check response timeout (1–20).
health_check_healthy_threshold number 1 no Successes before healthy (1–20).
health_check_unhealthy_threshold number 5 no Failures before unhealthy (1–20).
kms_key_arn string null no Customer-managed KMS key for image/log encryption.
permissions_boundary_arn string null no Permissions boundary for the generated access role.
tags map(string) {} no Extra tags merged onto all resources.

Outputs

Name Description
service_id The App Runner service ID.
service_arn ARN of the service (for IAM grants and alarms).
service_name Name of the service.
service_url The *.awsapprunner.com HTTPS domain.
status Current service status (e.g. RUNNING).
auto_scaling_configuration_arn ARN of the versioned auto scaling configuration.
vpc_connector_arn VPC connector ARN, or null for public egress.
access_role_arn Generated ECR access role ARN, or null for ECR_PUBLIC.

Enterprise scenario

A commerce platform runs roughly forty internal and customer-facing microservices, most of them stateless HTTP APIs that previously each carried their own ECS service, ALB listener rule, and target group. The platform team replaced that boilerplate with this single module: each service team supplies an image tag, a /healthz path, and a list of private subnets, and gets a managed HTTPS endpoint that egresses through a VPC connector to the shared Aurora cluster — with no ALBs to patch and request-driven scaling that drops idle services to a two-instance floor overnight. CI promotes a vetted image by bumping image_tag and running terraform apply (auto-deploy stays off in prod), so every rollout is an auditable, reviewable change rather than an opaque push.

Best practices

TerraformAWSApp RunnerModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading