Guide · AWS App Runner · VPC Connector · Private Network Access · MCP Servers

App Runner VPC Connector for MCP Servers Accessing Private Resources

By default, an App Runner service cannot reach resources inside a VPC — RDS databases, ElastiCache clusters, and internal ALBs are all unreachable. The VPC connector solves this: it places an elastic network interface (ENI) inside your VPC subnets, routing egress traffic from the App Runner service through your VPC's networking rules. For MCP servers, the VPC connector is essential whenever the tools need to read from a private PostgreSQL/MySQL database, write to an ElastiCache Redis cluster, or call internal microservices that aren't exposed to the internet. Three things to get right: subnet selection (subnets with routes to your RDS/ElastiCache subnet groups), security group rules (the connector's security group must be allowed as an inbound source on RDS and ElastiCache security groups), and egress mode (VPC egress disables the service's access to public internet endpoints — choose carefully).

TL;DR

Create a VpcConnector with apprunner.create_vpc_connector(), specifying SubnetIds (private subnets in your VPC) and a SecurityGroupId. Associate it with the service via NetworkConfiguration.EgressConfiguration.EgressType=VPC. On the RDS or ElastiCache security group, add an inbound rule allowing the connector's security group as a source. Cost: $0.05/hr per VPC connector-hour regardless of active connections.

Creating a VPC connector

A VPC connector is a reusable resource — create it once and attach it to multiple App Runner services:

import boto3

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

# Create a VPC connector in the same VPC as your RDS/ElastiCache
connector_response = apprunner.create_vpc_connector(
    VpcConnectorName="mcp-server-connector",
    Subnets=[
        "subnet-0abc123456789def0",  # private subnet in AZ us-east-1a
        "subnet-0def987654321abc0",  # private subnet in AZ us-east-1b
    ],
    SecurityGroups=[
        "sg-0aaaaaaaaaaaaaaa1",  # security group for the connector's ENI
    ],
)
connector_arn = connector_response["VpcConnector"]["VpcConnectorArn"]

# Attach the connector when creating (or updating) the App Runner service
apprunner.create_service(
    ServiceName="mcp-server-prod",
    # ... (SourceConfiguration, InstanceConfiguration as before)
    NetworkConfiguration={
        "EgressConfiguration": {
            "EgressType": "VPC",               # route egress through the VPC
            "VpcConnectorArn": connector_arn,
        },
        "IngressConfiguration": {
            "IsPubliclyAccessible": True,      # inbound HTTPS remains public
        },
    },
)

The EgressType=VPC routes all outbound traffic from the App Runner service through the VPC connector. If the MCP server also needs to reach public AWS API endpoints (S3, DynamoDB, Secrets Manager), add a NAT Gateway to the subnet's route table or use VPC interface endpoints for those services.

Security group rules for private RDS and ElastiCache

The VPC connector's ENI sends traffic using the security group you assigned it. The target resources (RDS, ElastiCache) must allow inbound from this security group:

import boto3

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

CONNECTOR_SG = "sg-0aaaaaaaaaaaaaaa1"   # assigned to the VPC connector
RDS_SG = "sg-0bbbbbbbbbbbbbb2"          # assigned to the RDS instance
ELASTICACHE_SG = "sg-0cccccccccccccc3"  # assigned to the ElastiCache cluster

# Allow App Runner → RDS PostgreSQL
ec2.authorize_security_group_ingress(
    GroupId=RDS_SG,
    IpPermissions=[{
        "IpProtocol": "tcp",
        "FromPort": 5432,
        "ToPort": 5432,
        "UserIdGroupPairs": [{"GroupId": CONNECTOR_SG}],
    }],
)

# Allow App Runner → ElastiCache Redis
ec2.authorize_security_group_ingress(
    GroupId=ELASTICACHE_SG,
    IpPermissions=[{
        "IpProtocol": "tcp",
        "FromPort": 6379,
        "ToPort": 6379,
        "UserIdGroupPairs": [{"GroupId": CONNECTOR_SG}],
    }],
)

# The connector's own security group needs NO inbound rules
# App Runner only uses the connector for outbound (egress) traffic

A common misconfiguration: adding rules to the connector's security group for inbound traffic. The connector only handles egress — App Runner routes outbound connections from the service through the connector's ENI. Inbound traffic to the App Runner service arrives via the service's own managed load balancer, not through the VPC connector.

Connecting to a private RDS PostgreSQL instance

Once the VPC connector is attached and security group rules are in place, the MCP server connects to RDS using the private DNS hostname exactly as it would from within an EC2 instance or ECS task:

import asyncpg
import os

# RDS private DNS — only resolvable inside the VPC
# Format: {identifier}.{region}.rds.amazonaws.com (private IP)
DATABASE_URL = os.environ["DATABASE_URL"]
# e.g. postgresql://mcpuser:pass@mcp-db.cluster-xyz.us-east-1.rds.amazonaws.com:5432/mcpdb

_pool = None

async def get_db_pool():
    global _pool
    if _pool is None:
        _pool = await asyncpg.create_pool(
            DATABASE_URL,
            min_size=2,
            max_size=10,
            command_timeout=30,
        )
    return _pool

# MCP tool handler using the private RDS connection
async def get_workspace_data(workspace_id: str) -> dict:
    pool = await get_db_pool()
    async with pool.acquire() as conn:
        row = await conn.fetchrow(
            "SELECT id, name, config FROM workspaces WHERE id = $1",
            workspace_id,
        )
        if row is None:
            raise ValueError(f"Workspace {workspace_id} not found")
        return dict(row)

App Runner resolves the RDS private DNS hostname via the VPC's internal DNS resolver (Route 53 Resolver, available at the VPC's base CIDR + 2, e.g., 10.0.0.2). The hostname resolves to the RDS instance's private IP address, which is reachable through the connector's ENI.

Egress modes: VPC vs DEFAULT and internet access

When EgressType=VPC is set, all outbound traffic goes through the VPC. Public internet access depends entirely on the VPC's networking:

# EgressType=DEFAULT (no VPC connector)
# ✓ Can reach public internet (S3, DynamoDB public endpoints, Slack API, etc.)
# ✗ Cannot reach private VPC resources (RDS, ElastiCache, internal ALBs)

# EgressType=VPC (with VPC connector)
# ✓ Can reach private VPC resources
# ✗ Public internet access depends on VPC routing:
#   - If subnets have a route to a NAT Gateway → public internet accessible
#   - If subnets are isolated (no NAT) → public internet BLOCKED

# For MCP servers that need BOTH private DB access AND external API calls:
# Option 1: Add NAT Gateway to the App Runner subnets
# Option 2: Use VPC Interface Endpoints for AWS services (S3, DynamoDB, Secrets Manager)
#            so those calls stay within the VPC without needing NAT

# Check which AWS services support VPC Interface Endpoints:
ec2 = boto3.client("ec2")
endpoints = ec2.describe_vpc_endpoint_services()
# Key services for MCP servers: com.amazonaws.us-east-1.s3
#   com.amazonaws.us-east-1.dynamodb, com.amazonaws.us-east-1.secretsmanager
#   com.amazonaws.us-east-1.bedrock-runtime, com.amazonaws.us-east-1.sagemaker-runtime

For MCP servers that call Anthropic's Claude API or other external APIs, the most cost-efficient architecture is: place the App Runner subnets in private subnets that route through a NAT Gateway. NAT Gateway costs $0.045/hr + $0.045/GB data transfer — for low-volume MCP servers this is cheaper than keeping a bastion or VPN active.

Connecting to ElastiCache Redis via VPC connector

ElastiCache Redis in cluster mode requires connecting to the configuration endpoint, not individual shard endpoints:

import redis.asyncio as redis
import os

# ElastiCache Redis cluster mode: use configuration endpoint
# Format: {cluster}.{id}.clustercfg.{region}.cache.amazonaws.com:6379
# For Redis non-cluster mode: {cluster}.{id}.{region}.cache.amazonaws.com:6379

REDIS_URL = os.environ["REDIS_URL"]  # set in App Runner environment variables

_redis_client = None

async def get_redis():
    global _redis_client
    if _redis_client is None:
        _redis_client = await redis.from_url(
            REDIS_URL,
            encoding="utf-8",
            decode_responses=True,
            socket_connect_timeout=5,
            socket_timeout=5,
        )
    return _redis_client

# MCP tool: cache tool results in Redis to reduce downstream API calls
async def cached_tool_call(tool_name: str, args_hash: str) -> str | None:
    r = await get_redis()
    return await r.get(f"mcp:tool:{tool_name}:{args_hash}")

async def cache_tool_result(tool_name: str, args_hash: str, result: str, ttl: int = 300):
    r = await get_redis()
    await r.setex(f"mcp:tool:{tool_name}:{args_hash}", ttl, result)

ElastiCache in-transit encryption (TLS) requires adding ssl=True to the Redis connection URL or client options. App Runner's VPC connector supports TLS connections to ElastiCache — the TLS handshake happens inside the VPC network path.

VPC connector versioning and updates

VPC connectors are immutable once created — you cannot change the subnets or security groups. To update the networking configuration, create a new connector version and update the service to reference it:

# Create a new connector with updated subnets (e.g., adding a third AZ)
new_connector = apprunner.create_vpc_connector(
    VpcConnectorName="mcp-server-connector",  # same name = new version
    Subnets=[
        "subnet-0abc123456789def0",  # us-east-1a
        "subnet-0def987654321abc0",  # us-east-1b
        "subnet-0ghi111222333jkl0",  # us-east-1c (new)
    ],
    SecurityGroups=["sg-0aaaaaaaaaaaaaaa1"],
)

# Update the service to use the new connector version
apprunner.update_service(
    ServiceArn="arn:aws:apprunner:us-east-1:123456789012:service/mcp-server-prod/...",
    NetworkConfiguration={
        "EgressConfiguration": {
            "EgressType": "VPC",
            "VpcConnectorArn": new_connector["VpcConnector"]["VpcConnectorArn"],
        },
        "IngressConfiguration": {"IsPubliclyAccessible": True},
    },
)

# Delete the old connector version once the service update completes
# (connectors with active service associations cannot be deleted)
apprunner.delete_vpc_connector(
    VpcConnectorArn="arn:aws:apprunner:...:vpcconnector/mcp-server-connector/1/old-id",
)

The VPC connector version number increments automatically when you create a new connector with the same name. The old connector remains active until the service update completes. Cost is $0.05/hr per VPC connector-hour — delete unused connector versions to avoid accumulating idle charges.

Monitor VPC-connected MCP endpoints with AliveMCP

VPC connector misconfigurations (wrong security group rules, subnet routing failures) cause MCP tool calls to hang indefinitely rather than fail fast. AliveMCP probes MCP server endpoints every 60 seconds from outside the VPC, ensuring the full network path — from public internet through App Runner through the VPC connector to private resources — is working end-to-end.

Join the waitlist →