Every serverless API eventually outgrows its https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/ URL. Customers want api.example.com. Mobile clients need a hostname that survives a stack rebuild. Security review wants a WAF and TLS policy you can point at. This tutorial wires all of that up for a Serverless Framework V4 service, stage by stage, with nothing clicked in the console.
We will build, in order:
- An ACM certificate for
*.example.com(and why the region matters twice). - An API Gateway custom domain name with base path mapping, so
api.example.com/ordersreaches the right service. - An optional CloudFront distribution in front for caching, custom headers, and a single global entry point.
- AWS WAF with rate limiting and managed rules.
- A cutover plan that does not break existing clients.
The examples assume an HTTP API (httpApi) on Serverless Framework V4, Node.js 22, and a Route 53 hosted zone for example.com. Everything works with a REST API too; the differences are called out.
Step 0: Decide whether you need CloudFront at all
This is the decision that saves the most money and grief, so make it first.
API Gateway custom domain alone is enough when:
- Your clients are in one or two regions.
- Responses are dynamic and uncacheable.
- You do not need to serve a web app from the same hostname.
Add CloudFront when you need:
- Edge caching of
GETresponses (CloudFront caches, API Gateway HTTP API does not). - One hostname serving both a static site (S3) and
/api/*(API Gateway). - Response headers policies, geo restriction, or origin failover between regions.
- A single place to attach WAF for both web and API traffic.
CloudFront in front of a regional API adds a hop (usually 10–30 ms for uncached, non-local traffic) and its own invalidation and caching semantics. Do not add it "for performance" without measuring; for a chatty write-heavy API it is often a small net negative.
Step 1: The ACM certificate (and the two-region trap)
Certificate region rules trip up nearly every first-time setup:
- An API Gateway regional custom domain needs the certificate in the same region as the API.
- A CloudFront distribution needs its certificate in us-east-1, always, regardless of where your Lambda functions live.
If you plan to add CloudFront later, issue two certificates now — one in your API region, one in us-east-1. They are free.
Define the regional one in resources so it lives with the service, or (better for anything shared across services) create it once in a small "platform" stack and import the ARN. Here is the shared-stack version, deployed by its own serverless.yml:
# platform/serverless.yml
service: platform-edge
provider:
name: aws
region: eu-west-1
resources:
Resources:
ApiCertificate:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: '*.example.com'
SubjectAlternativeNames:
- example.com
ValidationMethod: DNS
DomainValidationOptions:
- DomainName: '*.example.com'
HostedZoneId: !Ref HostedZoneId
Outputs:
ApiCertificateArn:
Value: !Ref ApiCertificate
Export:
Name: platform-edge-api-certificate-arn
With DomainValidationOptions.HostedZoneId set, CloudFormation writes the validation CNAME into Route 53 for you and the stack simply waits until the certificate is issued. Without it, the stack hangs in CREATE_IN_PROGRESS for an hour and then fails — if you see that, the missing DNS record is why.
Step 2: The API Gateway custom domain
There are two ways to do this, and the right one depends on how many services share the hostname.
Option A: The domain manager plugin (single service per hostname)
serverless-domain-manager is still the fastest path for one service that owns a subdomain:
plugins:
- serverless-domain-manager
custom:
customDomain:
domainName: api-${sls:stage}.example.com
certificateName: '*.example.com'
basePath: ''
endpointType: regional
apiType: http
securityPolicy: tls_1_2
createRoute53Record: true
autoDomain: false
Then, once per stage:
npx serverless create_domain --stage prod
npx serverless deploy --stage prod
Keep autoDomain: false. With it on, a serverless remove deletes the domain name resource, which takes the DNS record and every base path mapping with it — including mappings owned by other services. Creating domains as a deliberate, separate command is the whole point.
Option B: Declare the resources yourself (several services, one hostname)
When api.example.com/orders, /billing, and /search are three separate Serverless Framework services, the domain name is shared infrastructure and each service owns only its base path mapping. Put the domain in the platform stack:
ApiDomain:
Type: AWS::ApiGatewayV2::DomainName
Properties:
DomainName: api.example.com
DomainNameConfigurations:
- CertificateArn: !Ref ApiCertificate
EndpointType: REGIONAL
SecurityPolicy: TLS_1_2
ApiDomainRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: ${env:HOSTED_ZONE_ID}
Name: api.example.com
Type: A
AliasTarget:
DNSName: !GetAtt ApiDomain.RegionalDomainName
HostedZoneId: !GetAtt ApiDomain.RegionalHostedZoneId
And in each service, only the mapping:
resources:
Resources:
OrdersApiMapping:
Type: AWS::ApiGatewayV2::ApiMapping
Properties:
DomainName: api.example.com
ApiId: !Ref HttpApi
Stage: !Ref HttpApiStage
ApiMappingKey: orders
HttpApi and HttpApiStage are the logical IDs Serverless Framework generates for httpApi events; for a REST API they are ApiGatewayRestApi and the deployment stage. Run npx serverless package and grep .serverless/cloudformation-template-update-stack.json if you are unsure what your service generated.
With mappings in place, POST https://api.example.com/orders/v1/orders routes to the orders service's /v1/orders route. The base path is stripped before your handler sees the path, so your route definitions stay unaware of it.
Disable the default endpoint
Once the custom domain works, stop serving the raw execute-api URL, otherwise clients (and scanners) keep using it and it bypasses anything you attach at the edge:
provider:
httpApi:
disableDefaultEndpoint: true
For REST APIs, the equivalent is attaching a resource policy or, more simply, only advertising the custom domain and putting WAF on the stage.
Step 3: CloudFront in front (only if Step 0 said yes)
The important part of an API origin is what you do not cache and what you do forward.
ApiDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
Aliases:
- api.example.com
ViewerCertificate:
AcmCertificateArn: ${env:US_EAST_1_CERT_ARN}
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.2_2021
Origins:
- Id: HttpApiOrigin
DomainName: !Select [2, !Split ['/', !GetAtt HttpApi.ApiEndpoint]]
CustomOriginConfig:
OriginProtocolPolicy: https-only
OriginSSLProtocols: [TLSv1.2]
OriginCustomHeaders:
- HeaderName: x-origin-verify
HeaderValue: ${env:ORIGIN_SECRET}
DefaultCacheBehavior:
TargetOriginId: HttpApiOrigin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE]
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac # AllViewerExceptHostHeader
Three details that cause most CloudFront-in-front-of-API bugs:
Do not forward the Host header. API Gateway routes on Host. If CloudFront forwards api.example.com to an origin that does not have that domain configured, you get 403 Forbidden with an empty body. AllViewerExceptHostHeader is the managed policy that solves this; use it unless you have configured the custom domain on the origin itself.
Start with caching disabled. Attach CachingDisabled to the default behavior, then add a narrower behavior for the paths that are genuinely cacheable:
CacheBehaviors:
- PathPattern: /catalog/*
TargetOriginId: HttpApiOrigin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods: [GET, HEAD, OPTIONS]
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
OriginRequestPolicyId: b689b0a8-53d0-40ab-baf2-68738e2966ac
A cached authenticated endpoint leaks one user's data to another. If a behavior caches, make sure the cache policy includes Authorization in the cache key or the route requires no auth at all.
Lock the origin to CloudFront. The x-origin-verify custom header above is only useful if something checks it. Enforce it in a Lambda authorizer or at WAF on the API stage, and rotate the value in Secrets Manager. Otherwise anyone who finds the regional endpoint walks around your entire edge.
Step 4: WAF, rate limiting, and TLS policy
Attach a Web ACL — scope CLOUDFRONT in us-east-1 if you added CloudFront, scope REGIONAL in your API region if you did not:
ApiWebAcl:
Type: AWS::WAFv2::WebACL
Properties:
Name: api-${sls:stage}
Scope: REGIONAL
DefaultAction: { Allow: {} }
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: api-${sls:stage}
Rules:
- Name: RateLimitPerIp
Priority: 0
Action: { Block: {} }
Statement:
RateBasedStatement:
Limit: 2000 # requests per 5 minutes per IP
AggregateKeyType: IP
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: rate-limit
- Name: CommonRuleSet
Priority: 1
OverrideAction: { Count: {} } # start in count mode
Statement:
ManagedRuleGroupStatement:
VendorName: AWS
Name: AWSManagedRulesCommonRuleSet
VisibilityConfig:
SampledRequestsEnabled: true
CloudWatchMetricsEnabled: true
MetricName: common-rules
Deploy managed rule groups in Count mode first and watch the WAF sampled requests for a week. AWSManagedRulesCommonRuleSet blocks large request bodies and some JSON payload shapes that legitimate API clients send; flipping straight to Block on a production API is a reliable way to page yourself at 2 a.m. Switch to { Block: {} } per rule group only after the counters are clean.
Rate limiting deserves a second layer inside API Gateway too — WAF's rate rule is per IP over five minutes, which does nothing against a distributed abuser holding a valid API key. Use usage plans (REST) or per-route throttling:
provider:
httpApi:
metrics: true
# stage-level throttling for HTTP APIs
stackTags:
service: orders
resources:
Resources:
HttpApiStage:
Type: AWS::ApiGatewayV2::Stage
Properties:
DefaultRouteSettings:
ThrottlingBurstLimit: 200
ThrottlingRateLimit: 100
Step 5: Cutting over without breaking clients
A hostname migration is a one-way door for any client you cannot redeploy. The sequence we use:
- Stand the new domain up alongside the old URL. Both work. Nothing is switched off.
- Verify with real requests, not curl to
/health: run your integration suite againsthttps://api.example.comin a staging stage first. Check the certificate chain (openssl s_client -connect api.example.com:443), CORS preflights, and any request with a body over 1 MB. - Move traffic with DNS TTL under control. Set the record's TTL to 60 seconds a day before cutover, cut over, then raise it again once stable. Route 53 alias records to a CloudFront distribution or a regional API domain are free of TTL concerns, but any CNAME you are replacing is not.
- Watch four metrics for an hour:
4XXErrorand5XXErroron the API, CloudFrontTotalErrorRate, WAFBlockedRequests, and LambdaErrors. A spike in WAF blocks with flat Lambda errors means a managed rule is eating real traffic — flip that rule toCountimmediately. - Leave the old endpoint alive for a deprecation window (30–90 days for third-party clients), with a log-based alarm on any remaining usage so you know who to chase. Only then set
disableDefaultEndpoint: true.
Multi-stage naming
Use a hostname per stage from the start, driven by the stage variable, and never point two stages at the same hostname:
custom:
domains:
prod: api.example.com
staging: api-staging.example.com
dev: api-dev.example.com
hostname: ${self:custom.domains.${sls:stage}, 'api-dev.example.com'}
Ephemeral PR stages should keep the default execute-api URL. Creating and deleting custom domains per pull request burns ACM validations and Route 53 records for no benefit, and the ApiMapping delete path is where cross-stage outages come from.
Common failures and what they actually mean
| Symptom | Usual cause |
|---|---|
403 Forbidden, empty body, through CloudFront | Host header forwarded to the origin |
403 with {"message":"Forbidden"} direct to the domain | No base path mapping for that path |
SSL_ERROR / certificate mismatch | Certificate in the wrong region, or Alias missing from the distribution |
| Stack stuck creating the certificate | DNS validation records never written |
Deploy fails: domain name already exists | Domain created by another stack or by create_domain; import it, do not recreate |
| CORS works direct but not via CloudFront | OPTIONS not in AllowedMethods, or preflight cached by the wrong cache policy |
Where to stop
A custom domain on a regional API, TLS 1.2 minimum, one WAF rate rule, and per-stage hostnames covers the majority of production serverless APIs. Add CloudFront when there is a cacheable path or a shared web hostname, and add managed WAF rule groups deliberately, in count mode, one at a time.
If you are planning a hostname cutover for a live API — or untangling a domain that several Serverless Framework services already share — our team does this as a short, fixed-scope engagement. Get in touch and tell us what your current stack looks like.