AWS App Runner: Deploy Containerized Apps Without DevOps Overhead (2026)
AWS App Runner is the answer to a question every engineering team eventually asks: why do we need a platform engineer just to deploy a REST API? You have a Docker image. You want it on the internet with TLS, auto scaling, and health checks. You do not want to think about load balancers, task definitions, VPCs, or Auto Scaling group lifecycle hooks. App Runner does all of that in one service, in under five minutes, with a single configuration file.
Launched in 2021 and steadily maturing since, App Runner occupies a unique niche in the AWS compute portfolio: fully managed container hosting with concurrency-based auto scaling, automatic HTTPS, and native integration with ECR and GitHub. You push a commit or a new image tag, App Runner detects it and rolls out a new version — no CI/CD pipeline required unless you want one. For startups, internal tools, microservices, and any team without dedicated infrastructure expertise, App Runner reduces the operational surface area from hundreds of AWS resources down to a single service configuration.
This guide covers every production concern: choosing App Runner over Elastic Beanstalk, ECS Fargate, or Lambda; configuring services with apprunner.yaml; setting up VPC connectors to reach private databases; wiring custom domains; integrating GitHub Actions; enabling X-Ray tracing; pulling secrets at runtime; and understanding the cost model so you never pay for idle capacity.
Table of Contents
- App Runner vs Elastic Beanstalk vs ECS Fargate vs Lambda
- Source Types: ECR Image, GitHub, Public Registry
- Service Configuration: apprunner.yaml Deep Dive
- Auto Scaling: Concurrency-Based and Instance Limits
- VPC Connector: Accessing Private Resources
- Custom Domains and TLS Certificate Validation
- CI/CD: GitHub Actions and Automatic ECR Deployments
- Observability: CloudWatch, X-Ray, and Structured Logging
- Secrets and Configuration: Secrets Manager and Parameter Store
- Cost Model: Per-Second Billing and Pause Strategy
- Frequently Asked Questions
App Runner vs Elastic Beanstalk vs ECS Fargate vs Lambda
AWS offers four primary abstractions for running web-facing workloads, and the right choice depends on how much infrastructure control you need, how much operational overhead you can accept, and whether your workload is container-native. Getting this decision wrong costs weeks of rework, so spend ten minutes here before you write a line of Terraform.
| Factor | App Runner | Elastic Beanstalk | ECS Fargate | Lambda |
|---|---|---|---|---|
| Deployment artifact | Docker image or source repo | ZIP/JAR/WAR or Docker | Docker image only | ZIP or container image |
| Infrastructure to manage | None — zero config | Low (AWS manages EC2/ASG/ALB) | Medium (task defs, service, ALB) | None |
| Auto scaling model | Concurrency-based (unique) | CPU/memory ASG metrics | CPU/memory/custom metrics | Per-invocation (instant) |
| Cold starts | Warm instances kept (configurable min) | ASG-based, slow | Seconds to pull image | ms–seconds depending on runtime |
| VPC access | Via VPC Connector | Native VPC placement | Native VPC placement | Native VPC placement |
| Custom OS config | No — black box | Yes (.ebextensions / platform hooks) | Via custom Docker image | No |
| Built-in HTTPS | Yes (automatic TLS) | Yes (via ALB listener) | Yes (via ALB listener) | Yes (via API Gateway / Function URL) |
| Max request duration | 120 seconds | Unlimited (long-poll supported) | Unlimited | 15 minutes |
| Pause when idle | Yes — reduces cost by ~80% | No (EC2 runs 24/7) | No (task runs 24/7) | N/A (per-invocation billing) |
| CI/CD auto-deploy | Native (ECR push or git commit) | Via CodePipeline / EB deploy | Via ECS deployment / CodePipeline | Via CodeDeploy / Lambda aliases |
| Best for | Simple APIs, startups, no DevOps team | JAR/WAR apps, teams new to AWS | Microservices, mixed workloads | Event-driven, short-lived functions |
A practical rule: start with App Runner for net-new containerized services. If you hit a wall — long requests, background workers, complex networking — migrate to ECS Fargate. The Docker image you built for App Runner runs unchanged on ECS, so the migration cost is low.
Source Types: ECR Image, GitHub, Public Registry
App Runner supports three source types, each with different trade-offs for build control, deployment speed, and access configuration. Understanding these before you create a service prevents painful migrations later.
ECR Private Image
This is the recommended production pattern. You build and push your Docker image to Amazon ECR in your CI/CD pipeline, then point App Runner at the ECR repository URI. App Runner uses an access role to pull the image. When you push a new tag or update the tag App Runner is watching (typically latest or a semver tag), App Runner detects the change and automatically deploys a new revision.
# 1. Create an ECR repository
aws ecr create-repository \
--repository-name my-api \
--image-scanning-configuration scanOnPush=true \
--region us-east-1
# 2. Authenticate Docker to ECR
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS \
--password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
# 3. Build and push
docker build -t my-api:1.0.0 .
docker tag my-api:1.0.0 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-api:latest
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/my-api:latest
# 4. Create the App Runner access role (allows App Runner to pull from ECR)
aws iam create-role \
--role-name AppRunnerECRAccessRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "build.apprunner.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy \
--role-name AppRunnerECRAccessRole \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess
GitHub Source (Auto-Build)
App Runner can connect directly to a GitHub repository, pull your code on every commit to a chosen branch, and build the Docker image itself using Buildpacks (automatic) or your Dockerfile. This eliminates the need for a separate CI pipeline for simple services. App Runner creates an AWS CodeStar connection to your GitHub account during the setup wizard.
# Create a service from GitHub (CLI)
aws apprunner create-service \
--service-name my-api \
--source-configuration '{
"AuthenticationConfiguration": {
"ConnectionArn": "arn:aws:apprunner:us-east-1:123456789012:connection/my-github/abc123"
},
"AutoDeploymentsEnabled": true,
"CodeRepository": {
"RepositoryUrl": "https://github.com/myorg/my-api",
"SourceCodeVersion": {
"Type": "BRANCH",
"Value": "main"
},
"CodeConfiguration": {
"ConfigurationSource": "REPOSITORY"
}
}
}' \
--instance-configuration '{"Cpu":"1 vCPU","Memory":"2 GB"}'
REPOSITORY, App Runner reads your apprunner.yaml file from the repo root for build and runtime settings. When set to API, all configuration is passed via the CLI/API/Terraform and the repo file is ignored. For production, always use REPOSITORY so config is version-controlled alongside code.
Public ECR Image
You can also point App Runner at a public ECR image (from the AWS Public Gallery or Docker Hub via ECR Public). No access role is needed. This is useful for deploying off-the-shelf services like Redis HTTP proxies, admin panels, or third-party APIs without maintaining a private registry. Note that App Runner cannot auto-deploy on public image updates — you must trigger manually or via the API.
Service Configuration: apprunner.yaml Deep Dive
The apprunner.yaml file in your repository root is the single source of truth for how App Runner builds and runs your service. It covers the build command, start command, runtime port, environment variables, health checks, and concurrency settings. Here is a production-ready example for a Node.js API:
# apprunner.yaml (place in repository root)
version: 1.0
runtime: nodejs18 # or python311, corretto17, corretto11, go1, dotnet6
build:
commands:
pre-build:
- echo "Installing dependencies..."
- npm ci --only=production
build:
- echo "Running build step..."
- npm run build
post-build:
- echo "Build complete"
env:
- name: NODE_ENV
value: production
run:
command: node dist/server.js
network:
port: 8080
env: PORT # App Runner sets this env var to the port value
env:
- name: LOG_LEVEL
value: info
- name: DB_NAME
value: myapp_prod
- name: DB_PASSWORD
value-from: "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/db-password-AbCdEf:password::"
secrets:
- name: JWT_SECRET
value-from: "/myapp/jwt-secret" # SSM Parameter Store path
healthcheck:
protocol: HTTP
path: /health
interval: 10 # seconds between checks
timeout: 5
healthy-threshold: 1
unhealthy-threshold: 5
For a containerized service (ECR source), the apprunner.yaml controls only the runtime configuration — the image is pre-built. The build section is only used for GitHub source deployments where App Runner builds the image from source.
# apprunner.yaml for ECR-sourced service (runtime config only)
version: 1.0
run:
command: java -jar /app/app.jar
network:
port: 8080
env:
- name: SPRING_PROFILES_ACTIVE
value: prod
- name: SERVER_PORT
value: "8080"
- name: DB_URL
value-from: "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/db-url-XyZaBc:url::"
healthcheck:
protocol: HTTP
path: /actuator/health
interval: 10
timeout: 5
healthy-threshold: 1
unhealthy-threshold: 3
0.25 vCPU / 0.5 GB, 0.5 vCPU / 1 GB, 1 vCPU / 2 GB, 2 vCPU / 4 GB, and 4 vCPU / 8 GB. The pairing is fixed — you cannot mix 2 vCPU with 3 GB. Start with 1 vCPU / 2 GB and right-size based on CloudWatch metrics after a week of production traffic. Under-provisioning CPU causes request queuing even at low concurrency.
Auto Scaling: Concurrency-Based and Instance Limits
App Runner's auto scaling is conceptually different from ECS and Elastic Beanstalk, and this distinction matters for your application design. Instead of scaling on CPU utilization or memory pressure, App Runner scales on concurrent request count per instance. When the number of simultaneous in-flight requests on an instance exceeds the configured concurrency threshold, App Runner provisions a new instance. This means App Runner can add capacity before CPU spikes rather than reacting to them — which is exactly what you want for latency-sensitive APIs.
# Create a custom auto-scaling configuration
aws apprunner create-auto-scaling-configuration \
--auto-scaling-configuration-name my-api-scaling \
--max-concurrency 50 \
--min-size 1 \
--max-size 10
# The ARN is returned — reference it when creating or updating a service
# MaxConcurrency: when any instance handles >= 50 simultaneous requests,
# App Runner adds another instance (up to max-size)
# MinSize: minimum instances always running (set to 0 to enable pause mode)
# MaxSize: hard cap on instance count
App Runner's auto scaling configuration is a versioned resource — you create named configs and attach them to services. This allows you to reuse the same scaling profile across multiple services:
# List existing configurations
aws apprunner list-auto-scaling-configurations \
--auto-scaling-configuration-name my-api-scaling
# Update a service to use a new scaling config
aws apprunner update-service \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123 \
--auto-scaling-configuration-arn arn:aws:apprunner:us-east-1:123456789012:autoscalingconfiguration/my-api-scaling/1/abc456
Pause and Resume
App Runner's pause feature is a powerful cost-saving mechanism with no equivalent in ECS or Beanstalk. When paused, App Runner shuts down all compute instances but retains the service configuration, custom domain, and TLS certificate. The service URL returns HTTP 503 during the pause. Billing drops to near zero. Resume takes 1–2 minutes to warm up.
# Pause a service (e.g., staging environments overnight)
aws apprunner pause-service \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123
# Resume a service
aws apprunner resume-service \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123
# Automate pause/resume with EventBridge Scheduler
# Pause staging at 8 PM, resume at 8 AM (saves ~70% of staging costs)
aws scheduler create-schedule \
--name pause-staging-api \
--schedule-expression "cron(0 20 * * ? *)" \
--flexible-time-window '{"Mode": "OFF"}' \
--target '{
"Arn": "arn:aws:scheduler:::aws-sdk:apprunner:pauseService",
"RoleArn": "arn:aws:iam::123456789012:role/SchedulerRole",
"Input": "{\"ServiceArn\": \"arn:aws:apprunner:us-east-1:123456789012:service/my-api-staging/abc123\"}"
}'
min-size to 1 ensures one instance is always warm, eliminating cold starts for your users. Setting it to 0 enables full scale-to-zero (cheapest option) but the first request after a quiet period will experience a cold start of 5–30 seconds while App Runner provisions a new container instance. For production APIs, keep min-size at 1. For staging, set it to 0 and accept the occasional slow start.
VPC Connector: Accessing Private Resources
By default, App Runner runs your service in AWS-managed compute that has internet access but cannot reach your private VPC resources — your RDS database, ElastiCache cluster, or internal microservices. The VPC Connector feature creates an elastic network interface in your VPC subnets that App Runner uses as a private exit point. All outbound traffic from your service can be routed through this connector to reach private resources.
CLI Setup
# 1. Create the VPC Connector
# Use private subnets (at least two AZs for HA)
# Create a dedicated security group for App Runner egress
aws apprunner create-vpc-connector \
--vpc-connector-name my-api-vpc-connector \
--subnets subnet-0a1b2c3d4e5f6a7b8 subnet-0f9e8d7c6b5a4b3c2 \
--security-groups sg-0123456789abcdef0
# 2. Configure the RDS security group to allow traffic from App Runner's SG
aws ec2 authorize-security-group-ingress \
--group-id sg-rds-security-group-id \
--protocol tcp \
--port 5432 \
--source-group sg-0123456789abcdef0 # The App Runner connector SG
# 3. Attach the VPC connector to an existing service
aws apprunner update-service \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123 \
--network-configuration '{
"EgressConfiguration": {
"EgressType": "VPC",
"VpcConnectorArn": "arn:aws:apprunner:us-east-1:123456789012:vpcconnector/my-api-vpc-connector/1/abc123"
}
}'
Terraform Setup
# terraform/app_runner.tf
resource "aws_security_group" "app_runner_connector" {
name = "app-runner-connector-sg"
description = "Egress SG for App Runner VPC connector"
vpc_id = var.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_apprunner_vpc_connector" "main" {
vpc_connector_name = "my-api-vpc-connector"
subnets = var.private_subnet_ids
security_groups = [aws_security_group.app_runner_connector.id]
}
resource "aws_apprunner_service" "api" {
service_name = "my-api"
source_configuration {
authentication_configuration {
access_role_arn = aws_iam_role.app_runner_ecr.arn
}
auto_deployments_enabled = true
image_repository {
image_identifier = "${aws_ecr_repository.api.repository_url}:latest"
image_repository_type = "ECR"
image_configuration {
port = "8080"
runtime_environment_variables = {
DB_HOST = aws_db_instance.main.address
DB_PORT = "5432"
DB_NAME = "myapp_prod"
}
runtime_environment_secrets = {
DB_PASSWORD = aws_secretsmanager_secret.db_password.arn
JWT_SECRET = aws_ssm_parameter.jwt_secret.name
}
}
}
}
instance_configuration {
cpu = "1 vCPU"
memory = "2 GB"
instance_role_arn = aws_iam_role.app_runner_instance.arn
}
network_configuration {
egress_configuration {
egress_type = "VPC"
vpc_connector_arn = aws_apprunner_vpc_connector.main.arn
}
ingress_configuration {
is_publicly_accessible = true
}
}
health_check_configuration {
protocol = "HTTP"
path = "/health"
interval = 10
timeout = 5
healthy_threshold = 1
unhealthy_threshold = 5
}
auto_scaling_configuration_arn = aws_apprunner_auto_scaling_configuration_version.main.arn
}
resource "aws_apprunner_auto_scaling_configuration_version" "main" {
auto_scaling_configuration_name = "my-api-scaling"
max_concurrency = 50
min_size = 1
max_size = 10
}
output "app_runner_url" {
value = "https://${aws_apprunner_service.api.service_url}"
}
ingress_configuration.is_publicly_accessible = false, which makes the service reachable only from within your VPC (via a VPC Ingress Connection). This is the right pattern for internal APIs that should never be exposed to the public internet, while still benefiting from App Runner's fully managed runtime.
Custom Domains and TLS Certificate Validation
Every App Runner service gets a generated subdomain like abc1def23g.us-east-1.awsapprunner.com with automatic TLS. Associating a custom domain (e.g., api.example.com) requires adding a CNAME record and validating a TLS certificate. App Runner manages the certificate lifecycle automatically — renewals are hands-free.
# 1. Associate your custom domain
aws apprunner associate-custom-domain \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123 \
--domain-name api.example.com \
--enable-www-subdomain false
# 2. The response includes certificate validation records — add them to your DNS
# Example response structure:
# {
# "CustomDomain": {
# "DomainName": "api.example.com",
# "Status": "PENDING_CERTIFICATE_DNS_VALIDATION",
# "CertificateValidationRecords": [
# {
# "Name": "_abc123def456.api.example.com",
# "Type": "CNAME",
# "Value": "_xyz789.acm-validations.aws."
# }
# ]
# }
# }
# 3. Check association status (wait for ACTIVE)
aws apprunner describe-custom-domains \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123
After the ACM certificate validation CNAME is added to your DNS, add the actual traffic routing CNAME:
# DNS records to add at your registrar or Route 53:
# 1. Certificate validation (temporary, used once):
# _abc123def456.api.example.com CNAME _xyz789.acm-validations.aws.
# 2. Traffic routing (permanent):
# api.example.com CNAME abc1def23g.us-east-1.awsapprunner.com
# Route 53 example (using AWS CLI):
aws route53 change-resource-record-sets \
--hosted-zone-id Z1234567890ABC \
--change-batch '{
"Changes": [{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "api.example.com",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [{"Value": "abc1def23g.us-east-1.awsapprunner.com"}]
}
}]
}'
PENDING_CERTIFICATE_DNS_VALIDATION to ACTIVE, your custom domain serves traffic over HTTPS automatically. You can remove the validation CNAME after the certificate is issued — ACM will re-validate on renewal using a cached record.
CI/CD: GitHub Actions and Automatic ECR Deployments
App Runner's native auto-deploy feature watches for new ECR image pushes or Git commits and triggers a deployment automatically. For most teams, this is sufficient. For teams that need manual approval gates, branch-based deployment logic, or integration tests before deployment, a GitHub Actions workflow gives you full control.
Automatic ECR Deployment (No CI/CD required)
When you enable AutoDeploymentsEnabled: true on an ECR-sourced service, App Runner polls ECR for changes to the watched image tag every minute. When it detects a new image digest, it starts a deployment automatically. This works well for small teams deploying frequently — just push to ECR and App Runner handles the rest.
GitHub Actions Workflow with Manual ECR Push
# .github/workflows/deploy.yml
name: Build and Deploy to App Runner
on:
push:
branches: [main]
workflow_dispatch: # allow manual trigger from GitHub UI
env:
AWS_REGION: us-east-1
ECR_REGISTRY: 123456789012.dkr.ecr.us-east-1.amazonaws.com
ECR_REPOSITORY: my-api
APP_RUNNER_SERVICE_ARN: arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # required for OIDC auth
contents: read
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC — no long-lived keys)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsAppRunner
aws-region: ${{ env.AWS_REGION }}
- name: Log in to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set image tag
id: tag
run: echo "IMAGE_TAG=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Build Docker image
run: |
docker build \
--build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
--build-arg VCS_REF=${{ github.sha }} \
-t $ECR_REGISTRY/$ECR_REPOSITORY:${{ steps.tag.outputs.IMAGE_TAG }} \
-t $ECR_REGISTRY/$ECR_REPOSITORY:latest \
.
- name: Run container security scan
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.ECR_REGISTRY }}/${{ env.ECR_REPOSITORY }}:${{ steps.tag.outputs.IMAGE_TAG }}
format: table
exit-code: 1
severity: CRITICAL
- name: Push image to ECR
run: |
docker push $ECR_REGISTRY/$ECR_REPOSITORY:${{ steps.tag.outputs.IMAGE_TAG }}
docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest
- name: Deploy to App Runner
run: |
aws apprunner start-deployment \
--service-arn $APP_RUNNER_SERVICE_ARN
- name: Wait for deployment to complete
run: |
echo "Waiting for App Runner deployment..."
for i in $(seq 1 30); do
STATUS=$(aws apprunner describe-service \
--service-arn $APP_RUNNER_SERVICE_ARN \
--query 'Service.Status' --output text)
echo "Status: $STATUS (attempt $i/30)"
if [ "$STATUS" = "RUNNING" ]; then
echo "Deployment succeeded!"
exit 0
fi
if [ "$STATUS" = "OPERATION_IN_PROGRESS" ]; then
sleep 15
continue
fi
echo "Unexpected status: $STATUS"
exit 1
done
echo "Timed out waiting for deployment"
exit 1
GitHubActionsAppRunner has a trust policy allowing the GitHub OIDC provider to assume it for your specific repository. This is a security best practice — no secrets to rotate, no keys to leak.
The Dockerfile powering this workflow follows multi-stage build best practices to minimize image size and attack surface:
# Dockerfile (multi-stage, production-ready)
FROM node:18-alpine AS builder
WORKDIR /build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:18-alpine AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# Copy only production dependencies
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
# Copy built artifacts
COPY --from=builder /build/dist ./dist
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
CMD ["node", "dist/server.js"]
Observability: CloudWatch, X-Ray, and Structured Logging
App Runner emits service-level metrics to CloudWatch automatically and supports AWS X-Ray for distributed tracing. Setting up proper observability from day one prevents painful debugging sessions in production.
CloudWatch Metrics
App Runner publishes the following metrics to the AWS/AppRunner namespace. All are available at the service level, and most can be split by instance ID:
| Metric | Unit | Alert Threshold Suggestion |
|---|---|---|
| RequestLatency | Milliseconds (p50/p95/p99) | p99 > 2000ms |
| 2xxStatusResponses | Count | Monitor for drops |
| 4xxStatusResponses | Count | > 5% of total requests |
| 5xxStatusResponses | Count | Any in 1 minute |
| ActiveInstances | Count | Alert if hitting MaxSize limit |
| CPUUtilization | Percent | > 80% sustained 5 minutes |
| MemoryUtilization | Percent | > 85% sustained 5 minutes |
# Create a CloudWatch alarm for 5xx errors
aws cloudwatch put-metric-alarm \
--alarm-name "my-api-5xx-errors" \
--namespace "AWS/AppRunner" \
--metric-name "5xxStatusResponses" \
--dimensions Name=ServiceName,Value=my-api Name=ServiceId,Value=abc123 \
--statistic Sum \
--period 60 \
--threshold 5 \
--comparison-operator GreaterThanThreshold \
--evaluation-periods 1 \
--treat-missing-data notBreaching \
--alarm-actions arn:aws:sns:us-east-1:123456789012:alerts
# Create alarm for p99 latency
aws cloudwatch put-metric-alarm \
--alarm-name "my-api-p99-latency" \
--namespace "AWS/AppRunner" \
--metric-name "RequestLatency" \
--dimensions Name=ServiceName,Value=my-api \
--extended-statistic p99 \
--period 300 \
--threshold 2000 \
--comparison-operator GreaterThanThreshold \
--evaluation-periods 2 \
--alarm-actions arn:aws:sns:us-east-1:123456789012:alerts
X-Ray Tracing
Enable X-Ray tracing in the App Runner service configuration. App Runner injects the X-Ray daemon as a sidecar — you only need to instrument your application code with the X-Ray SDK:
# Enable X-Ray when creating or updating a service
aws apprunner update-service \
--service-arn arn:aws:apprunner:us-east-1:123456789012:service/my-api/abc123 \
--observability-configuration '{
"ObservabilityEnabled": true,
"ObservabilityConfigurationArn": "arn:aws:apprunner:us-east-1:123456789012:observabilityconfiguration/DefaultConfiguration/1/00000000000000000000000000000001"
}'
# In your Node.js application:
# npm install aws-xray-sdk-core aws-xray-sdk-express
// src/instrumentation.js — load before any other module
const AWSXRay = require('aws-xray-sdk-core');
const http = require('http');
const https = require('https');
// Capture all outgoing HTTP/HTTPS calls
AWSXRay.captureHTTPsGlobal(http);
AWSXRay.captureHTTPsGlobal(https);
// Capture all AWS SDK calls
const AWS = AWSXRay.captureAWS(require('aws-sdk'));
module.exports = { AWSXRay, AWS };
// In your Express server:
const AWSXRay = require('aws-xray-sdk-core');
const xrayExpress = require('aws-xray-sdk-express');
const express = require('express');
const app = express();
app.use(xrayExpress.openSegment('my-api'));
// ... your routes ...
app.use(xrayExpress.closeSegment());
Structured Logging Best Practices
App Runner streams stdout and stderr to CloudWatch Logs automatically. Structure your logs as JSON so CloudWatch Logs Insights can query them efficiently:
// Structured logging with pino (Node.js)
const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
formatters: {
level: (label) => ({ level: label }),
},
base: {
service: 'my-api',
version: process.env.APP_VERSION || 'unknown',
environment: process.env.NODE_ENV,
},
});
// Log format: {"level":"info","service":"my-api","requestId":"abc123","method":"GET","path":"/users","duration":45,"status":200}
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info({
requestId: req.headers['x-amzn-trace-id'],
method: req.method,
path: req.path,
status: res.statusCode,
duration: Date.now() - start,
});
});
next();
});
# Query logs with CloudWatch Logs Insights
# Find slow requests (duration > 1000ms)
# In the CloudWatch Logs Insights console, select /aws/apprunner/my-api/*/application:
fields @timestamp, requestId, method, path, duration, status
| filter duration > 1000
| sort duration desc
| limit 20
Secrets and Configuration: Secrets Manager and Parameter Store
Hardcoding database passwords or API keys in environment variables stored in your service configuration is a security antipattern. App Runner supports pulling secrets from AWS Secrets Manager and SSM Parameter Store at container startup, injecting them as environment variables. The values are never stored in the App Runner service configuration — only the ARN or parameter path is stored.
IAM Role for Secret Access
App Runner uses an instance role (separate from the ECR access role) to call Secrets Manager and SSM at runtime. Create this role and attach it to your service:
# Create the instance role
aws iam create-role \
--role-name AppRunnerInstanceRole \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "tasks.apprunner.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
# Attach policy for Secrets Manager access
aws iam put-role-policy \
--role-name AppRunnerInstanceRole \
--policy-name SecretsAccess \
--policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": [
"arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/*"
]
},
{
"Effect": "Allow",
"Action": ["ssm:GetParameters"],
"Resource": [
"arn:aws:ssm:us-east-1:123456789012:parameter/myapp/*"
]
},
{
"Effect": "Allow",
"Action": ["kms:Decrypt"],
"Resource": [
"arn:aws:kms:us-east-1:123456789012:key/your-kms-key-id"
]
}
]
}'
Storing and Referencing Secrets
# Store database credentials in Secrets Manager (JSON format)
aws secretsmanager create-secret \
--name myapp/database \
--description "Production database credentials" \
--secret-string '{
"host": "mydb.cluster.us-east-1.rds.amazonaws.com",
"port": "5432",
"dbname": "myapp_prod",
"username": "myapp_user",
"password": "super-secure-password-here"
}'
# Store a simple string in SSM Parameter Store (SecureString)
aws ssm put-parameter \
--name "/myapp/jwt-secret" \
--value "your-256-bit-jwt-secret-here" \
--type SecureString \
--key-id alias/aws/ssm
In apprunner.yaml, reference secrets using their full ARN (for Secrets Manager JSON fields) or parameter path (for SSM):
# apprunner.yaml — secrets injection
version: 1.0
run:
command: node dist/server.js
network:
port: 8080
env:
- name: NODE_ENV
value: production
secrets:
# Secrets Manager — inject a specific JSON key from the secret
# Format: arn:...secret-name:json-key::
- name: DB_HOST
value-from: "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/database-AbCdEf:host::"
- name: DB_PASSWORD
value-from: "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/database-AbCdEf:password::"
- name: DB_NAME
value-from: "arn:aws:secretsmanager:us-east-1:123456789012:secret:myapp/database-AbCdEf:dbname::"
# SSM Parameter Store — inject full parameter value
- name: JWT_SECRET
value-from: "/myapp/jwt-secret"
RotationSucceeded Secrets Manager event, triggering a Lambda that calls apprunner:StartDeployment.
Cost Model: Per-Second Billing and Pause Strategy
App Runner billing has two components: active compute (when handling requests) and provisioned compute (when instances are warm but idle). Understanding both prevents bill shock and allows you to optimize significantly with the pause feature.
| Component | Price (us-east-1, 2026) | Notes |
|---|---|---|
| Active vCPU | $0.064 per vCPU-hour | Charged per second during request processing |
| Active memory | $0.007 per GB-hour | Charged per second during request processing |
| Provisioned vCPU | $0.0064 per vCPU-hour | 10% of active rate — charged when instance is warm but idle |
| Provisioned memory | $0.0007 per GB-hour | 10% of active rate — charged when idle |
| Automatic builds | $0.005 per build minute | GitHub source only |
Cost Comparison: App Runner vs ECS Fargate (Low-Traffic API)
Scenario: Single-instance API, 1 vCPU / 2 GB, 10% active utilization (low traffic)
App Runner:
Active (10% of 720 hrs) = 72 hrs × $0.064 vCPU + 72 hrs × $0.014 GB = $4.608 + $1.008 = $5.62
Provisioned (90%) = 648 hrs × $0.0064 + 648 hrs × $0.0014 = $4.147 + $0.907 = $5.05
Total (1 instance) = ~$10.67/month
ECS Fargate (always running, 1 vCPU / 2 GB):
vCPU: 720 hrs × $0.04048 = $29.15
Memory: 720 hrs × 2 GB × $0.004445 = $6.40
ALB: ~$16-22/month
Total = ~$51-57/month
Winner for low traffic: App Runner saves ~$40-46/month per service.
With pause (8 hours/day off — staging environment):
App Runner running 16hrs/day = 480 hrs × full rate ≈ $7.11
Savings vs always-on: ~33%
Savings vs Fargate: ~$43-50/month
Cost Optimization Checklist
# 1. Right-size instances — use CloudWatch to find actual CPU/memory usage
aws cloudwatch get-metric-statistics \
--namespace AWS/AppRunner \
--metric-name CPUUtilization \
--dimensions Name=ServiceName,Value=my-api \
--start-time $(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%SZ) \
--end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
--period 3600 \
--statistics Average Maximum
# 2. Pause non-production services at night (saves ~70% of provisioned costs)
# Use EventBridge Scheduler (shown in Auto Scaling section above)
# 3. Set appropriate max-concurrency to avoid over-scaling
# If your app handles 100 concurrent requests comfortably, set max-concurrency=80
# (leave 20% headroom, not 200%)
# 4. Use ECR lifecycle policies to clean up old images (no cost impact on App Runner,
# but ECR storage costs add up at $0.10/GB/month)
aws ecr put-lifecycle-policy \
--repository-name my-api \
--lifecycle-policy '{
"rules": [{
"rulePriority": 1,
"selection": {"tagStatus": "untagged", "countType": "sinceImagePushed", "countUnit": "days", "countNumber": 7},
"action": {"type": "expire"}
}]
}'
Frequently Asked Questions
Not directly. App Runner is designed for request-driven HTTP workloads. For background workers, use ECS Fargate tasks with an SQS trigger, or AWS Lambda with EventBridge scheduling. A common pattern is to run the API on App Runner and handle background processing in a separate ECS service or Lambda function, communicating via SQS or EventBridge.
When all instances are at maximum concurrency and the service is at max-size, new incoming requests are queued briefly. If they cannot be served within the timeout period (120 seconds for App Runner), they receive a 504 response. Monitor the ActiveInstances metric and alert when it approaches max-size so you can adjust the auto-scaling configuration before this becomes a user-facing issue.
Yes. Create a separate App Runner service for each microservice, each pointing at its own ECR repository or a different subdirectory with its own Dockerfile. The GitHub source type also supports specifying a root directory within the repo. Each service has independent scaling, configuration, and deployment lifecycle — which is exactly the microservices model you want.
App Runner performs rolling deployments by default — it starts new instances with the new image before terminating old ones, ensuring zero downtime. True blue/green (where you can instantly roll back by switching traffic) requires two App Runner services (blue and green) behind an ALB or a Route 53 weighted routing policy, toggling traffic between them. This adds complexity — for most teams, App Runner's built-in rolling deployment with the "previous deployment" rollback button in the console is sufficient.
App Runner supports HTTP/1.1 and HTTP/2. WebSockets require HTTP/1.1 upgrade support, which App Runner does provide. gRPC over HTTP/2 is also supported. The 120-second request timeout applies to the full duration of the connection, so long-lived WebSocket connections may be terminated. For persistent WebSocket applications, consider ECS with an NLB instead of an ALB-backed service like App Runner.