Skip to content
AWS Deployment (ECS Fargate)

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_URL secret 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 createdWhy
A VPC or subnetsYou pass existing ones.
The pool token secretYou 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/8

CAPABILITY_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.internal

It 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/healthz

Verify

  • Both tasks show as healthy in the target group.
  • GET /healthz returns 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/32
  • LoadBalancerSubnetIds must 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.
  • AllowedClientCidr must actually admit your clients. Leaving it at the 10.0.0.0/8 default with InternetFacing=true builds 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/32

There 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-0123456789abcdef0

For 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-south

Because 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-deployment

The 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

SymptomCauseFix
Tasks start then stopMissing or unreadable secret, or an unreachable databaseCheck the CloudWatch log group; the Gateway names configuration problems explicitly.
Task cannot reach the tenantNo NAT from the task subnetsGive the private subnets outbound access.
Stack stuck in CREATE_IN_PROGRESS on the certificateACM is waiting for DNS validation you have to add by handYou omitted HostedZoneId. Add the CNAME ACM names in the console, or delete the stack and redeploy with the zone.
Certificate never validatesThe hostname has no public DNS zone to validate againstACM public certificates need public validation. Use CertificateArn with a private-CA or imported certificate.
CannotPullContainerError / Docker Hub 429No NAT from the task subnets, or the anonymous pull limit was hitConfirm outbound internet access, then pass DockerHubSecretArn to pull authenticated.
Load balancer unreachable from the internetInternetFacing=true with private LB subnets or an unchanged AllowedClientCidrSee Internal or Internet-Facing - all three parameters change together.
Some clients reach the Gateway, others time outTheir address is in no CIDR slot and not in the prefix listAdd it as AllowedClientCidr2/3, or to your prefix list - the latter needs no stack update.
Stack creation rejected for an invalid engine versionRDS retired the pinned Postgres minor versionSet DbEngineVersion to a version aws rds describe-db-engine-versions --engine postgres offers.
Target group never healthyHealth check path or port wrong, or the security group blocks the load balancerThe template sets /healthz on 8080; confirm nothing overrode it.
ResourceInitializationError reading secretsExecution role cannot read the ARNs you passedConfirm the ARNs are in the same account and region.
Tasks exit naming the tenantThe tasks cannot reach your tenant, or the pool token was rejectedFetching settings is fatal at startup by design. Check NAT or VPC endpoints from the service subnets, then the token.
OAuth fails at the callbackThe pool’s Public base URL doesn’t match DNSMake them agree in the tenant portal.
A settings change hasn’t taken effectThe next sync hasn’t run, or the last one failedThe console’s Settings tab shows what each task is running and flags stale settings.
Approval-gated calls time outIdle timeout lowered below the approval windowRaise it on the load balancer.

Next Steps

Last updated on