Guide · AWS App Runner · CI/CD · ECR Auto-Deploy · MCP Servers

App Runner CI/CD for MCP Servers — ECR Auto-Deploy, GitHub Source, Rollback

App Runner handles rolling updates, health checks during deployment, and automatic rollback on health check failure — the platform does the blue/green mechanics so you don't have to configure load balancer rules or manage deployment tasks manually. For MCP server operators, the key CI/CD decisions are: ECR image source vs GitHub source (ECR gives more control over the build pipeline; GitHub source is faster for small teams without a separate CI system), automatic vs manual deployment triggers (auto-deploy on every ECR push works well for feature branches; manual deploy lets you control production rollout timing), and rollback strategy (App Runner holds the previous service configuration, allowing a one-API-call rollback by updating back to the previous image URI). Three operational patterns matter most: using immutable image tags (not :latest) for production deployments, monitoring the system log group during deployments for health check failures, and using the start_deployment() API to trigger deployments without changing configuration.

TL;DR

For ECR-based deployments: tag production images with the git commit SHA (e.g., :main-abc1234), not :latest. Use apprunner.update_service() with the new image URI to deploy. Enable AutoDeploymentsEnabled=True on dev/staging; keep it disabled for production (deploy explicitly via pipeline). Monitor /aws/apprunner/{name}/{id}/system CloudWatch log group during deployments — health check failures appear here before the service rolls back.

ECR-based deployment: image source configuration

The production-safe ECR deployment pattern uses immutable image tags tied to git commit SHAs:

import boto3
import subprocess

apprunner = boto3.client("apprunner", region_name="us-east-1")
ecr = boto3.client("ecr", region_name="us-east-1")

AWS_ACCOUNT = "123456789012"
REGION = "us-east-1"
REPO = "mcp-server"
SERVICE_ARN = "arn:aws:apprunner:us-east-1:123456789012:service/mcp-server-prod/..."

def get_git_sha() -> str:
    return subprocess.check_output(["git", "rev-parse", "--short", "HEAD"]).decode().strip()

def build_and_push(tag: str) -> str:
    """Build Docker image and push to ECR. Returns full image URI."""
    registry = f"{AWS_ACCOUNT}.dkr.ecr.{REGION}.amazonaws.com"
    full_tag = f"{registry}/{REPO}:{tag}"

    # Authenticate docker to ECR
    token = ecr.get_authorization_token()["authorizationData"][0]["authorizationToken"]
    import base64
    user, password = base64.b64decode(token).decode().split(":", 1)
    subprocess.run(["docker", "login", "--username", user, "--password", password, registry], check=True)

    # Build and push
    subprocess.run(["docker", "build", "-t", full_tag, "."], check=True)
    subprocess.run(["docker", "push", full_tag], check=True)
    return full_tag

def deploy_to_apprunner(image_uri: str):
    """Update App Runner service to use the new image."""
    apprunner.update_service(
        ServiceArn=SERVICE_ARN,
        SourceConfiguration={
            "AuthenticationConfiguration": {
                "AccessRoleArn": "arn:aws:iam::123456789012:role/AppRunnerECRAccessRole"
            },
            "AutoDeploymentsEnabled": False,  # manual deploys for production
            "ImageRepository": {
                "ImageIdentifier": image_uri,
                "ImageRepositoryType": "ECR",
                "ImageConfiguration": {"Port": "8080"},
            },
        },
    )
    print(f"Deployment triggered for image: {image_uri}")

# Usage in CI pipeline:
# tag = f"main-{get_git_sha()}"
# image_uri = build_and_push(tag)
# deploy_to_apprunner(image_uri)

Using immutable image tags (commit SHA) means every deployment is auditable — you can determine exactly which code version is running by looking at the service's current image URI. Deploying :latest makes rollback impossible without knowing which specific image hash to revert to.

GitHub source-based builds: App Runner as the CI system

For teams without a separate CI pipeline, App Runner can build directly from a GitHub repository on each push to a branch:

# GitHub source deployment — App Runner builds the image from source
apprunner.create_service(
    ServiceName="mcp-server-staging",
    SourceConfiguration={
        "AuthenticationConfiguration": {
            # GitHub connection ARN — created via App Runner console
            # or apprunner.create_connection() → authorizes GitHub app
            "ConnectionArn": "arn:aws:apprunner:us-east-1:123456789012:connection/github/abc123"
        },
        "AutoDeploymentsEnabled": True,  # deploy on every push to the branch
        "CodeRepository": {
            "RepositoryUrl": "https://github.com/your-org/mcp-server",
            "SourceCodeVersion": {
                "Type": "BRANCH",
                "Value": "main",          # deploy on every push to main
            },
            "CodeConfiguration": {
                "ConfigurationSource": "API",  # config here, not in apprunner.yaml
                "CodeConfigurationValues": {
                    "Runtime": "PYTHON_3",
                    "BuildCommand": "pip install -r requirements.txt",
                    "StartCommand": "uvicorn main:app --host 0.0.0.0 --port 8080",
                    "Port": "8080",
                    "RuntimeEnvironmentVariables": {
                        "LOG_LEVEL": "info",
                        "MCP_TRANSPORT": "sse",
                    },
                },
            },
        },
    },
    InstanceConfiguration={
        "Cpu": "1 vCPU",
        "Memory": "2 GB",
        "InstanceRoleArn": "arn:aws:iam::123456789012:role/MCPServerInstanceRole",
    },
)

GitHub source builds take 2–5 minutes (source checkout + build command + health checks). ECR image builds are faster for production (30–90s) because the image build happens in your CI system in parallel with tests — by the time CI completes, the image is already in ECR and App Runner only needs to pull it.

Deployment mechanics: zero-downtime rolling updates

App Runner's update procedure is a rolling deployment managed entirely by the platform:

# App Runner deployment sequence for a service update:
# 1. New instances start with the new image (old instances continue serving)
# 2. Health checks run on new instances (HealthCheckConfiguration.Interval × HealthyThreshold)
# 3. Once new instances pass health checks, traffic shifts:
#    - Gradual: new instances receive increasing traffic while old instances drain
#    - Active SSE connections on old instances: maintained until client disconnects
#      OR until App Runner's drain timeout (approximately 30–60s after traffic shift)
# 4. Old instances are terminated after drain completes
# 5. If new instances FAIL health checks: rollback — old instances keep all traffic

# Monitor deployment status:
import time

def monitor_deployment(service_arn: str, timeout_seconds: int = 600):
    deadline = time.time() + timeout_seconds
    while time.time() < deadline:
        response = apprunner.describe_service(ServiceArn=service_arn)
        service = response["Service"]
        status = service["Status"]
        current_image = (
            service
            .get("SourceConfiguration", {})
            .get("ImageRepository", {})
            .get("ImageIdentifier", "N/A")
        )
        print(f"Status: {status} | Image: {current_image}")

        if status == "RUNNING":
            print("Deployment complete — service is RUNNING with new image")
            return service
        if status in ("UPDATE_FAILED", "CREATE_FAILED"):
            raise RuntimeError(
                f"Deployment failed: {status}. "
                f"Check system logs: /aws/apprunner/{service['ServiceName']}/{service['ServiceId']}/system"
            )

        time.sleep(15)

    raise TimeoutError("Deployment did not complete within timeout")

Rollback: redeploying the previous image

App Runner does not have a built-in "rollback" button, but rolling back is a single update_service() call with the previous image URI:

import boto3

apprunner = boto3.client("apprunner", region_name="us-east-1")
ecr = boto3.client("ecr", region_name="us-east-1")

REPO_NAME = "mcp-server"
SERVICE_ARN = "arn:aws:apprunner:us-east-1:123456789012:service/mcp-server-prod/..."

def get_previous_image_uri(repo_name: str, current_tag: str) -> str:
    """Find the ECR image pushed before the current tag."""
    response = ecr.describe_images(
        repositoryName=repo_name,
        filter={"tagStatus": "TAGGED"},
    )
    # Sort by push date, descending
    images = sorted(
        response["imageDetails"],
        key=lambda img: img["imagePushedAt"],
        reverse=True,
    )
    account_id = boto3.client("sts").get_caller_identity()["Account"]
    region = "us-east-1"
    for img in images:
        for tag in img.get("imageTags", []):
            if tag != current_tag and tag.startswith("main-"):
                return f"{account_id}.dkr.ecr.{region}.amazonaws.com/{repo_name}:{tag}"
    raise ValueError(f"No previous image found in {repo_name}")

def rollback(service_arn: str, rollback_image_uri: str):
    print(f"Rolling back to: {rollback_image_uri}")
    apprunner.update_service(
        ServiceArn=service_arn,
        SourceConfiguration={
            "AuthenticationConfiguration": {
                "AccessRoleArn": "arn:aws:iam::123456789012:role/AppRunnerECRAccessRole"
            },
            "AutoDeploymentsEnabled": False,
            "ImageRepository": {
                "ImageIdentifier": rollback_image_uri,
                "ImageRepositoryType": "ECR",
                "ImageConfiguration": {"Port": "8080"},
            },
        },
    )
    print("Rollback deployment triggered — monitor service status")

Keep the last 5–10 ECR image tags in your ECR lifecycle policy to ensure rollback targets are available. ECR lifecycle policy: {"countType": "imageCountMoreThan", "countNumber": 10, "selection": "taggedImages", "tagPrefixList": ["main-"]} — delete images older than the 10 most recent tagged with the main- prefix.

Manual deployment trigger without configuration change

If AutoDeploymentsEnabled=True is set, App Runner deploys automatically on new ECR pushes. For manual deploys with auto-deployment disabled, use start_deployment() to re-deploy the same image (e.g., to pick up updated environment variables or instance role changes):

# Trigger a re-deployment of the current image — useful after:
# - Updating environment variables via update_service()
# - Rotating secrets (force container restart to pick up new values)
# - Clearing a stuck deployment state

apprunner.start_deployment(ServiceArn=SERVICE_ARN)

# Or use the AWS CLI:
# aws apprunner start-deployment --service-arn {SERVICE_ARN}

# Force-restart pattern for secret rotation:
# 1. Rotate the secret in Secrets Manager
# 2. Call start_deployment() — new containers start and load the new secret at startup
# 3. Old containers drain gracefully
# Note: App Runner containers do NOT hot-reload environment variables.
#       Rotation always requires a deployment to take effect.

GitHub Actions workflow for App Runner deployments

A complete GitHub Actions workflow that builds, pushes to ECR, and deploys to App Runner:

# .github/workflows/deploy.yml
name: Deploy MCP Server to App Runner

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write   # for OIDC-based AWS auth
      contents: read

    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GithubActionsDeployRole
          aws-region: us-east-1

      - name: Login to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push image
        id: build
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: main-${{ github.sha }}
        run: |
          docker build -t $REGISTRY/mcp-server:$IMAGE_TAG .
          docker push $REGISTRY/mcp-server:$IMAGE_TAG
          echo "image_uri=$REGISTRY/mcp-server:$IMAGE_TAG" >> $GITHUB_OUTPUT

      - name: Deploy to App Runner
        run: |
          aws apprunner update-service \
            --service-arn ${{ secrets.APP_RUNNER_SERVICE_ARN }} \
            --source-configuration "{
              \"AuthenticationConfiguration\": {
                \"AccessRoleArn\": \"${{ secrets.ECR_ACCESS_ROLE_ARN }}\"
              },
              \"AutoDeploymentsEnabled\": false,
              \"ImageRepository\": {
                \"ImageIdentifier\": \"${{ steps.build.outputs.image_uri }}\",
                \"ImageRepositoryType\": \"ECR\",
                \"ImageConfiguration\": {\"Port\": \"8080\"}
              }
            }"

      - name: Wait for deployment
        run: |
          aws apprunner wait service-updated \
            --service-arn ${{ secrets.APP_RUNNER_SERVICE_ARN }}

The aws apprunner wait service-updated command polls the service status and exits when the service reaches RUNNING or a terminal failure state — providing clear pass/fail signal to the CI pipeline.

Verify App Runner deployments with external health checks

App Runner's internal health checks confirm the container started — they don't verify that MCP tool calls actually work end-to-end after a deployment. AliveMCP probes MCP endpoints from outside AWS on every deployment, confirming that tool calls return expected responses before marking a deployment healthy. Catches regression that pass internal health checks but fail real user interactions.

Join the waitlist →