AWS App Runner: Deploy Containerized Apps Without DevOps Overhead (2026)

AWS App Runner Container Deployment

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.

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.

FactorApp RunnerElastic BeanstalkECS FargateLambda
Deployment artifactDocker image or source repoZIP/JAR/WAR or DockerDocker image onlyZIP or container image
Infrastructure to manageNone — zero configLow (AWS manages EC2/ASG/ALB)Medium (task defs, service, ALB)None
Auto scaling modelConcurrency-based (unique)CPU/memory ASG metricsCPU/memory/custom metricsPer-invocation (instant)
Cold startsWarm instances kept (configurable min)ASG-based, slowSeconds to pull imagems–seconds depending on runtime
VPC accessVia VPC ConnectorNative VPC placementNative VPC placementNative VPC placement
Custom OS configNo — black boxYes (.ebextensions / platform hooks)Via custom Docker imageNo
Built-in HTTPSYes (automatic TLS)Yes (via ALB listener)Yes (via ALB listener)Yes (via API Gateway / Function URL)
Max request duration120 secondsUnlimited (long-poll supported)Unlimited15 minutes
Pause when idleYes — reduces cost by ~80%No (EC2 runs 24/7)No (task runs 24/7)N/A (per-invocation billing)
CI/CD auto-deployNative (ECR push or git commit)Via CodePipeline / EB deployVia ECS deployment / CodePipelineVia CodeDeploy / Lambda aliases
Best forSimple APIs, startups, no DevOps teamJAR/WAR apps, teams new to AWSMicroservices, mixed workloadsEvent-driven, short-lived functions
When App Runner wins: You have a containerized API or web app and you want it production-ready in under 10 minutes without learning ECS task definitions, ALB target groups, or Auto Scaling policies. Your team lacks infrastructure expertise. You want automatic deployments on every ECR push or git commit. Your workload has variable traffic with quiet periods — App Runner's pause mode cuts costs by ~80% when no requests are flowing. App Runner's concurrency-based scaling is also uniquely well-suited to bursty HTTP workloads: it scales on the number of concurrent requests per instance rather than lagging CPU metrics.
When to choose something else: App Runner has a hard 120-second request timeout — use ECS or Beanstalk for long-running streaming or batch requests. App Runner cannot run background workers, cron jobs, or SQS consumers without an external trigger — use ECS for those sidecar patterns. If you need OS-level customization, SSH access, or custom Nginx configs, Elastic Beanstalk or ECS is the better fit.

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"}'
ConfigurationSource: REPOSITORY vs API: When set to 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
CPU and Memory options (2026): App Runner supports 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 instances and cold starts: Setting 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}"
}
VPC Connector and private-only ingress: As of 2024, App Runner supports 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"}]
      }
    }]
  }'
Certificate validation time: DNS propagation for the ACM validation CNAME can take from 5 minutes to 48 hours depending on your DNS provider's TTL settings and propagation delays. The App Runner console shows the domain status. Once it transitions from 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
OIDC instead of static AWS keys: The workflow above uses GitHub's OIDC provider to authenticate to AWS without any long-lived access keys stored in GitHub secrets. The IAM role 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:

MetricUnitAlert Threshold Suggestion
RequestLatencyMilliseconds (p50/p95/p99)p99 > 2000ms
2xxStatusResponsesCountMonitor for drops
4xxStatusResponsesCount> 5% of total requests
5xxStatusResponsesCountAny in 1 minute
ActiveInstancesCountAlert if hitting MaxSize limit
CPUUtilizationPercent> 80% sustained 5 minutes
MemoryUtilizationPercent> 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"
Secret rotation and App Runner: App Runner reads secrets once at container startup. If you rotate a Secrets Manager secret, existing instances continue using the old value until the next deployment. To pick up a rotated secret, trigger a new App Runner deployment after rotation completes. Automate this with an EventBridge rule on the 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.

ComponentPrice (us-east-1, 2026)Notes
Active vCPU$0.064 per vCPU-hourCharged per second during request processing
Active memory$0.007 per GB-hourCharged per second during request processing
Provisioned vCPU$0.0064 per vCPU-hour10% of active rate — charged when instance is warm but idle
Provisioned memory$0.0007 per GB-hour10% of active rate — charged when idle
Automatic builds$0.005 per build minuteGitHub 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
When Fargate is cheaper: For high-traffic services running at >70% utilization 24/7, ECS Fargate becomes cost-competitive because you are paying the active rate nearly continuously anyway. ECS Fargate also has no per-request overhead and allows finer-grained CPU/memory combinations. Run the numbers for your specific traffic pattern before committing — a service handling 1,000 RPS will have very different economics than an internal tool serving 10 RPS.

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

Q: Can App Runner run background workers or cron jobs?

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.

Q: What happens if my App Runner service runs out of instances (hits max-size)?

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.

Q: Can I use App Runner with a monorepo containing multiple services?

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.

Q: How do I do blue/green deployments with App Runner?

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.

Q: Does App Runner support WebSockets or gRPC?

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.