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 →