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
- You are deploying a stateless HTTP API, web frontend, or background-light service from a container image in ECR and you do not want to operate ECS, EKS, or an ALB.
- You want scale-to-low or scale-to-a-floor behaviour driven by concurrent requests, billed for active container time, without writing scaling policies or CloudWatch alarms.
- Your service must reach private resources (RDS, ElastiCache, an internal NLB) and you need egress through a VPC connector rather than the public internet.
- You are standardising many small services across teams and want one audited module that enforces health-check paths, HTTPS-only ingress, encryption, and tagging.
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 config — live/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 config — live/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
- Keep the two roles straight. The module’s access role lets App Runner’s control plane pull from private ECR; your application’s AWS calls (S3, DynamoDB, KMS) must use a separate instance role passed via
instance_role_arnand scoped with least privilege — never reuse the access role for runtime permissions. - Inject secrets, don’t templatise them. Use
environment_secretswith Secrets Manager / SSM ARNs so values resolve at runtime and never land in Terraform state or the plan; reserveenvironment_variablesfor non-sensitive config only. - Pin and review auto scaling. Always set
min_size,max_size, andmax_concurrencydeliberately — the module versions the configuration immutably, and a sensiblemin_size(1–2) trades a little cost for eliminated cold starts on latency-sensitive paths whilemax_sizecaps blast-radius spend. - Use a VPC connector only when you need private reachability. Egress through a connector adds ENIs and crosses your NAT path; leave
egress_subnetsempty for services that only call public endpoints, and tightenegress_security_groupsto the exact downstream ports (e.g. 5432 for Postgres). - Encrypt with a customer-managed key and a real health path. Set
kms_key_arnto bring App Runner’s stored image and logs under your own key and rotation policy, and pointhealth_check_pathat a genuine readiness endpoint (/healthz) rather than/, so a half-initialised instance is never sent traffic. - Name for traceability. Give
service_namean environment-suffixed, team-scoped value (e.g.orders-api-prod); the module propagates it to the scaling config, access role, and VPC connector, keeping every related resource greppable in the console and in CloudTrail.