AWS Deployment (ECS Fargate)
This guide deploys the Gateway on ECS Fargate (containers without servers to manage) behind an Application Load Balancer, using one CloudFormation template.
Because all Gateway state lives in Postgres, the tasks need no storage, no stickiness, and no ordering - this is an ordinary horizontally-scaled web service.
Overview
The template creates:
- An Application Load Balancer, internal by default, with an HTTPS listener.
- An ECS cluster and a Fargate service running N Gateway tasks.
- A target group health-checking
/healthz, with stickiness off. - Security groups so only the load balancer can reach the tasks, and only the tasks can reach the database.
- An RDS Postgres instance, its generated password, and the
DATABASE_URLsecret the tasks read. - An ACM certificate for the HTTPS listener, validated automatically when you supply a Route 53 hosted zone.
- A Route 53 alias record for your hostname, when you supply that zone.
- A CloudWatch log group for container output.
- IAM roles - one to pull the image and read your secrets, one for the task itself.
The last three groups are defaults, not requirements. Supply
DatabaseUrlSecretArn and no database is created; supply CertificateArn and no
certificate is created. See Bring Your Own Database or
Certificate.
It deliberately does not create:
| Not created | Why |
|---|---|
| A VPC or subnets | You pass existing ones. |
| The pool token secret | You create it first, so the token is never a CloudFormation parameter in plaintext. |
Architecture
flowchart LR
Clients["MCP clients and agents"]
Britive["Britive tenant"]
Backends["Backend MCP servers"]
subgraph LbSubnets["Load balancer subnets"]
ALB["Application Load Balancer<br/>HTTPS 443"]
end
subgraph TaskSubnets["Private task subnets"]
T1["Gateway task"]
T2["Gateway task"]
end
RDS[("RDS Postgres<br/>(created by the stack)")]
Clients -->|"HTTPS"| ALB
ALB -->|"HTTP 8080"| T1
ALB -->|"HTTP 8080"| T2
T1 --> RDS
T2 --> RDS
T1 -->|"outbound via NAT"| Britive
T1 -->|"outbound"| Backends
Before You Begin
You need:
- An existing VPC with two or more subnets for the load balancer, and private subnets for the tasks and the database.
- A NAT gateway routed from the task subnets. The image is pulled from Docker Hub, so the tasks need outbound internet access - as they also do to reach your Britive tenant and your backend MCP servers. ECR and S3 VPC endpoints do not substitute here; they do not front Docker Hub.
- A hostname, and ideally its Route 53 hosted zone. With the zone, the stack validates the certificate and creates the DNS record with no manual step.
- A Britive tenant and pool token.
- Permission to create IAM roles, ECS, RDS, ACM, and load balancer resources.
Bringing your own Postgres or your own certificate is fully supported and skips the matching prerequisite. Jump to Bring Your Own Database or Certificate.
Deploy
Download the template
Download mcp-gateway-aws.cfn.yaml - CloudFormation for an ALB plus an ECS Fargate service.
Create the pool token secret
The template reads credentials from Secrets Manager rather than taking them as parameters, so they never appear in the stack’s inputs or events.
aws secretsmanager create-secret \
--name britive/mcp-gateway/pool-token \
--secret-string '<your gateway pool token>'Note the ARN it returns. This is the only secret you create by hand - the database password is generated inside the stack and never passes through your shell.
There is no master secret to create or rotate. The key that protects stored tokens and sessions is minted by the platform for the gateway pool and delivered with the pool’s settings, so every task agrees on it and it survives every deployment.
Deploy the stack
aws cloudformation deploy \
--template-file mcp-gateway-aws.cfn.yaml \
--stack-name britive-mcp-gateway \
--capabilities CAPABILITY_IAM \
--parameter-overrides \
Tenant=acme \
GatewayPoolTokenSecretArn=<arn> \
VpcId=vpc-xxxxxxxx \
LoadBalancerSubnetIds=subnet-aaa,subnet-bbb \
ServiceSubnetIds=subnet-ccc,subnet-ddd \
DomainName=mcp-gateway.example.com \
HostedZoneId=Z0123456789ABCDEFGHIJ \
AllowedClientCidr=10.0.0.0/8CAPABILITY_IAM is required because the template creates the two task roles.
That is the whole input: Postgres, the certificate, and the DNS record are created for you. Expect roughly 10-15 minutes, most of it waiting on RDS.
Omit HostedZoneId and the stack will sit in CREATE_IN_PROGRESS - potentially for
hours - while ACM waits for you to add the validation CNAME it asks for by hand.
Pass the zone whenever you have it.
ACM validates against public DNS even when the load balancer is internal. A
hostname that exists only in a private zone cannot be issued a public certificate;
pass CertificateArn for a private-CA or imported certificate instead.
Check the outputs, and point DNS if you need to
aws cloudformation describe-stacks \
--stack-name britive-mcp-gateway \
--query 'Stacks[0].Outputs'If you passed HostedZoneId, the alias record already exists and PublicBaseUrl
is the value to use in the next step. Otherwise create a DNS record yourself for
the hostname clients will use, pointing at LoadBalancerDnsName.
Set the public base URL on the pool
In the Britive tenant portal, open the gateway pool your token belongs to and set Public base URL to that hostname:
https://mcp-gateway.example.internalIt is a pool setting rather than a stack parameter, so the running tasks pick it up on their next sync - within five minutes by default - with no redeploy.
It must match the hostname clients actually use, because OAuth redirect and resource
URLs are built from it. If DNS and this value disagree, sign-in fails at the callback.
Leave it unset and the tasks fall back to http://localhost:8080 with a warning,
which no client can complete a sign-in against.
Confirm the service is healthy
aws ecs describe-services \
--cluster britive-mcp-gateway \
--services <service-name> \
--query 'services[0].{running:runningCount,desired:desiredCount}'Then, from inside the VPC (or through your VPN if the load balancer is internal):
curl -s https://mcp-gateway.example.internal/healthzVerify
- Both tasks show as healthy in the target group.
GET /healthzreturns 200 through the load balancer.- Signing in at the Gateway’s root URL reaches the admin console.
- Restarting one task leaves you signed in - proof the tasks share state through Postgres.
- Container logs appear in the CloudWatch log group named in the stack outputs.
Bring Your Own Database or Certificate
Creating both is the default, not a requirement. Each is switched off by supplying the resource you already have.
Your own Postgres. Pass DatabaseUrlSecretArn and the database, its subnet
group, its security group, and both secrets are skipped entirely - along with every
Db* parameter. The Gateway needs Postgres 14 or newer and takes a single
connection string:
aws secretsmanager create-secret \
--name britive/mcp-gateway/database-url \
--secret-string 'postgresql://gateway:<password>@your-db-host:5432/gateway'Your own certificate. Pass CertificateArn and no certificate is created. This
is the route for a private-CA or imported certificate, which is what an internal
hostname with no public zone requires.
Passing DomainName and HostedZoneId alongside your own CertificateArn still
gets you the alias record, so the two choices are independent.
Internal or Internet-Facing
The load balancer is internal by default, which is the right choice unless clients genuinely reach the Gateway from the public internet. Going internet-facing means changing three things together:
InternetFacing=true \
LoadBalancerSubnetIds=<public subnets> \
AllowedClientCidr=203.0.113.7/32LoadBalancerSubnetIdsmust be public subnets - a route table with a default route to an internet gateway, in two or more availability zones. The task subnets stay private either way; tasks never get a public IP.AllowedClientCidrmust actually admit your clients. Leaving it at the10.0.0.0/8default withInternetFacing=truebuilds a load balancer that no client on the internet can reach, and nothing in the stack events will say so.
An internet-facing Gateway should be reachable from your VPN egress addresses
rather than the whole internet. Do not use 0.0.0.0/0 unless you genuinely mean
it.
Allowing several client addresses
There are three CIDR slots, which covers the usual handful of VPN egress addresses:
AllowedClientCidr=203.0.113.7/32 \
AllowedClientCidr2=198.51.100.24/32 \
AllowedClientCidr3=192.0.2.19/32There are four client sources - these three slots and the prefix list below - and every one of them is individually optional. Each one you set adds a rule; each one you leave empty adds nothing. At least one must be set, so the stack cannot deploy a load balancer nothing can reach.
AllowedClientCidr has a default, not a requirement. Blank it to drop its rule
entirely, which is how you deploy against a prefix list alone:
AllowedClientCidr= \
AllowedClientPrefixListId=pl-0123456789abcdef0For more than three addresses, or addresses that change often, put them in a
customer-managed prefix list and pass its pl- ID as
AllowedClientPrefixListId. It is optional and additive - it combines with any
CIDR slots you also set, and the stack never creates one itself:
aws ec2 create-managed-prefix-list \
--prefix-list-name corp-vpn-egress \
--address-family IPv4 --max-entries 20 \
--entries Cidr=203.0.113.7/32,Description=vpn-us-east \
Cidr=198.51.100.24/32,Description=vpn-eu-west \
Cidr=192.0.2.19/32,Description=vpn-ap-southBecause the list lives outside the stack, editing it later takes effect immediately with no stack update - which is the reason to prefer it when VPN addresses churn.
Size the list deliberately: a prefix list referenced by a security group consumes its maximum entries against that group’s rule quota, not the entries actually in use. AWS is explicit that a 20-entry list “counts as 20 security group rules”, against a quota that defaults to 60.
There is no comma-separated list parameter for the CIDRs, and that is a
CloudFormation limitation rather than a choice: CommaDelimitedList parses, but
nothing in the base language can expand one value into several rules.
Fn::Select past the end of a short list fails the deployment, and counting a list
needs Fn::Length - available only under the AWS::LanguageExtensions transform,
which resolves parameters to literal values and so breaks redeploys that reuse the
previous template. Fixed slots plus an optional prefix list avoid the macro
entirely.
Things the Template Sets Deliberately
These settings exist because the defaults would break real usage, or lose data.
A 300-second load balancer idle timeout. A Britive checkout that requires human approval can outlast the default 60 seconds, and the connection being cut is what fails the tool call. This is an ingress timeout to raise, not a Gateway defect.
Stickiness off, MinimumHealthyPercent 100. Any task can serve any request, so
there is nothing to pin a client to, and a rolling deployment keeps full capacity
while it rotates tasks.
A deployment circuit breaker that rolls back. A task that cannot start - a bad image tag, an unreachable database - fails the deployment and reverts, rather than flapping indefinitely.
DeletionPolicy: Snapshot on the database, and Retain on its password. The
audit trail and the stored OAuth tokens live in this database, so deleting the stack
takes a final snapshot instead of destroying that history. The password secret is
retained alongside it because without it that snapshot cannot be restored. Both
outlive the stack, and both are yours to clean up when you are certain.
Storage encrypted, seven days of backups. Same reasoning: this is an audit
store, not scratch space. DbBackupRetentionDays=0 turns backups off if you really
want that.
Scaling and Upgrades
Change the replica count:
aws cloudformation deploy --template-file mcp-gateway-aws.cfn.yaml \
--stack-name britive-mcp-gateway \
--capabilities CAPABILITY_IAM \
--parameter-overrides DesiredCount=4 <plus your other parameters>To upgrade the image, force a new deployment - the tasks pull the current image and run migrations on startup:
aws ecs update-service \
--cluster britive-mcp-gateway \
--service <service-name> \
--force-new-deploymentThe image is pulled straight from Docker Hub - there is no ECR repository to create
or mirror into. For a repeatable deployment, pin ImageUri to a specific vX.Y.Z
tag rather than latest, so what runs is what you chose; on latest a moved tag is
only picked up by a new deployment.
Docker Hub rate-limits anonymous pulls per source IP, and every task shares the one
NAT address. Normal deployments stay well inside that, but a crash-looping service
can trip it - pass DockerHubSecretArn, naming a secret shaped
{"username":"...","password":"..."}, to pull authenticated instead.
Troubleshoot
| Symptom | Cause | Fix |
|---|---|---|
| Tasks start then stop | Missing or unreadable secret, or an unreachable database | Check the CloudWatch log group; the Gateway names configuration problems explicitly. |
| Task cannot reach the tenant | No NAT from the task subnets | Give the private subnets outbound access. |
Stack stuck in CREATE_IN_PROGRESS on the certificate | ACM is waiting for DNS validation you have to add by hand | You omitted HostedZoneId. Add the CNAME ACM names in the console, or delete the stack and redeploy with the zone. |
| Certificate never validates | The hostname has no public DNS zone to validate against | ACM public certificates need public validation. Use CertificateArn with a private-CA or imported certificate. |
CannotPullContainerError / Docker Hub 429 | No NAT from the task subnets, or the anonymous pull limit was hit | Confirm outbound internet access, then pass DockerHubSecretArn to pull authenticated. |
| Load balancer unreachable from the internet | InternetFacing=true with private LB subnets or an unchanged AllowedClientCidr | See Internal or Internet-Facing - all three parameters change together. |
| Some clients reach the Gateway, others time out | Their address is in no CIDR slot and not in the prefix list | Add it as AllowedClientCidr2/3, or to your prefix list - the latter needs no stack update. |
| Stack creation rejected for an invalid engine version | RDS retired the pinned Postgres minor version | Set DbEngineVersion to a version aws rds describe-db-engine-versions --engine postgres offers. |
| Target group never healthy | Health check path or port wrong, or the security group blocks the load balancer | The template sets /healthz on 8080; confirm nothing overrode it. |
ResourceInitializationError reading secrets | Execution role cannot read the ARNs you passed | Confirm the ARNs are in the same account and region. |
| Tasks exit naming the tenant | The tasks cannot reach your tenant, or the pool token was rejected | Fetching settings is fatal at startup by design. Check NAT or VPC endpoints from the service subnets, then the token. |
| OAuth fails at the callback | The pool’s Public base URL doesn’t match DNS | Make them agree in the tenant portal. |
| A settings change hasn’t taken effect | The next sync hasn’t run, or the last one failed | The console’s Settings tab shows what each task is running and flags stale settings. |
| Approval-gated calls time out | Idle timeout lowered below the approval window | Raise it on the load balancer. |