overcast local AWS emulator

Getting Started

Networking and host-based addressing

Path-style vs Host-routed AWS endpoints, the *.localhost.overcast.sh wildcard DNS option, and what to use offline.

Networking and host-based addressing

Overcast listens on a single port (default 4566) and dispatches every request — regardless of service — to the same emulator process. Real AWS services are split two ways depending on how a client addresses a resource:

  • Path-style: the resource ID is in the URL path (http://localhost:4566/restapis/{apiId}/{stage}/_user_request_/...). This always works against Overcast with zero configuration and is what every AWS SDK falls back to when it isn’t told a custom endpoint resolves a Host-based URL.
  • Host-routed (subdomain) style: the resource ID (and often the region) is encoded in the Host header instead — {apiId}.execute-api.{region}.amazonaws.com/{stage}/.... Some AWS features are only reachable this way on real AWS (Lambda function URLs have no path-style equivalent at all).

Overcast supports both. This page covers the Host-routed side: what’s implemented, the DNS story that makes it work locally, and the tradeoffs.


What works today

ServiceHost patternNotes
API Gateway (REST v1){apiId}.execute-api.{region}.{base}/{stage}/...Stage is always the first path segment, same as real AWS.
API Gateway (HTTP v2){apiId}.execute-api.{region}.{base}/...$default stage: no stage segment. Named stages: {stage}/... prefix, resolved against your API’s actual stages.
Lambda function URLs{urlId}.lambda-url.{region}.{base}/...No path-style equivalent — this is the only way to invoke a function URL, on Overcast and on real AWS alike. See Lambda function URLs below.
AppSync (GraphQL){apiId}.appsync-api.{region}.{base}/graphqlAlso reachable at /realtime on the same host — Overcast colocates the GraphQL and realtime endpoints.
AppSync (subscriptions){apiId}.appsync-realtime-api.{region}.{base}The host real AWS serves subscriptions on, and the one Amplify derives by substituting into the GraphQL URL. Routes to the same endpoint as /realtime above.
CloudFront{distributionId}.cloudfront.{base}Global, so there is no region segment. DomainName is minted on the hostname you reached Overcast on rather than the literal cloudfront.net.
S3 (virtual-hosted style){bucket}.s3[.{region}].{base}/... or {bucket}.{base}/...Both forms are supported. The second is what an AWS SDK emits against a custom endpoint with path-style disabled, and the only form CDK’s asset publisher uses. See sdk-cli.md and cdk.md.

Every Host-routed request is rewritten internally onto the same handlers path-style requests use, so behavior (authorizers, stage variables, integration dispatch, event publishing) is identical either way — pick whichever addressing style your client/SDK produces.

The Host is case-insensitive in every part — the resource ID, the service label, the region segment and the base domain alike — because a hostname is (RFC 4343). So E1PQRS2T3U4V5W.cloudfront.localhost.overcast.sh:4566 and the all-lowercase form a browser actually sends reach the same distribution, and MyBucket.localhost:4566 reaches bucket mybucket. Paths are case-sensitive, as they are on AWS: /_overcast/cloudfront/distributions/{distributionId}/... and every other path-style route must match exactly.

How Overcast decides who owns a Host

S3 virtual-hosted addressing and the host-routed services above share one hostname space, so Overcast resolves them with a single rule, applied in order:

  1. {bucket}.s3.{...} / {bucket}.s3-{region}.{...} → S3. The bucket is everything before the first .s3., so bucket names containing dots stay addressable here.
  2. {bucket}.{base} → S3, where {base} is localhost, localhost.overcast.sh, localhost.localstack.cloud, or your OVERCAST_HOSTNAME. Exception: if the part in front of the base carries a service label (execute-api, lambda-url, appsync-api, appsync-realtime-api, cloudfront) as its second or later dot-segment, it is a service address, not a bucket — rule 3 takes it.
  3. {id}.{label}[.{region}].{base} → the owning service, for the labels in the table above.
  4. Anything else stays path-style and reaches S3, which is the emulator’s catch-all.

The order is fixed by this rule, not by internal configuration, so the same Host always resolves the same way.

Reserved service labels. A bucket name whose second or later dot-segment is execute-api, lambda-url, appsync-api, appsync-realtime-api or cloudfront cannot be addressed by rule 2 — my.execute-api.localhost is an API Gateway invoke. Use path-style (localhost:4566/my.execute-api/key) or the explicit form (my.execute-api.s3.localhost), both of which work.

A bucket named exactly like a label is unaffected: execute-api.localhost is the bucket execute-api, because a host-routed address always has a non-empty resource ID in front of the label.

Overcast warns at CreateBucket when a name carries a reserved label, naming both escapes. The bucket is still created — AWS accepts the name, so refusing it would fail a stack locally that deploys fine against AWS.

Known AWS resource subdomains

AWS does not publish a single list of the hostnames that carry a resource ID. The SDK endpoint rulesets (endpoints.json, per-service endpoint-rules.json) and Smithy’s endpointPrefix cover control-plane endpoints only — neither contains execute-api, lambda-url or appsync-api. Those are documented per-service, scattered across AWS’s docs. This table is therefore the centralised list, and it is maintained by hand.

FormOvercastNotes
{apiId}.execute-api.{region}.{base}✅ routedAPI Gateway REST v1 and HTTP v2 invoke
{urlId}.lambda-url.{region}.{base}✅ routedLambda function URLs
{apiId}.appsync-api.{region}.{base}✅ routedAppSync GraphQL
{apiId}.appsync-realtime-api.{region}.{base}✅ routedAppSync subscriptions. Amplify derives this host by substituting into the GraphQL URL, so it must route even though Overcast serves both endpoints from one place
{distributionId}.cloudfront.net✅ routedCloudFront distribution. Global, so there is no region segment
{bucket}.s3[.{region}].{base}✅ routedS3 virtual-hosted style
{bucket}.s3.dualstack.{region}.{base}✅ routedMatched by the .s3. rule
{bucket}.s3-{region}.{base}✅ routedLegacy dash dialect, pre-2019 regions
{bucket}.s3-accelerate.{base}✅ routedTransfer Acceleration. Routing only — no acceleration is emulated, and AWS forbids periods in bucket names used with it
{bucket}.s3-website[-.]{region}.{base}⚠️ routedReaches S3, but static website hosting behaviour is not implemented
{ap}-{account}.s3-accesspoint.{region}.{base}⚠️ routedReaches S3 treating the access point alias as a bucket name; access points are not modelled
{bucket}.{base}✅ routedBare form. No real-AWS equivalent, but what an SDK emits against a custom endpoint with path-style disabled, and the only form CDK’s asset publisher uses
{domain}.auth.{region}.amazoncognito.comCognito hosted UI. Not routed; use the path-style endpoint
{id}.{hash}.{region}.rds.amazonaws.comEngine data-plane endpoint
{cluster}.{hash}.{region}.cache.amazonaws.comEngine data-plane endpoint
b-{n}.{cluster}.{hash}.kafka.{region}.amazonaws.comEngine data-plane endpoint
{domain}.{region}.es.amazonaws.comEngine data-plane endpoint

The engine data-plane endpoints are deliberately absent. Overcast emulates those services’ control planes, so there is no engine behind the hostname to route to — and every one of those labels (rds, cache, kafka, es, auth) is a bare common word. Registering one would make a bucket named my.cache unaddressable in the bare form for no benefit. See the guardrail in internal/middleware/hostroute.go.

{base} is whatever hostname the request actually arrived on — Overcast never hardcodes a domain. Point requests at localhost, an OVERCAST_HOSTNAME-configured wildcard domain (below), or a Docker service name; the response always echoes back the same base you called in.

Lambda function URLs

CreateFunctionUrlConfig / GetFunctionUrlConfig / UpdateFunctionUrlConfig / DeleteFunctionUrlConfig / ListFunctionUrlConfigs are implemented under the real API paths (/2021-10-31/functions/{name}/url[s]). The returned FunctionUrl always uses the Host you called Overcast on:

http://<url-id>.lambda-url.<region>.<host>:<port>/

A few things are intentionally simplified relative to real AWS, consistent with Overcast not being a security boundary:

  • AuthType (NONE / AWS_IAM) is stored and returned but never enforced. Every Host-routed invocation runs as if AuthType were NONE, regardless of what was configured.
  • Cors is stored, returned, and reflected onto invoke responses (Access-Control-Allow-* headers) — this is the one piece of CORS behavior actually applied, since it’s cheap and matters for browser-based testing against a function URL.
  • InvokeMode: RESPONSE_STREAM is accepted but always behaves as BUFFERED — there is no streaming function-URL invocation path in this emulator.
  • Qualifier is stored for API-shape correctness but not enforced against invocation — Overcast’s Lambda emulator already treats aliases/versions as metadata rather than separate executable snapshots (see InvokeFunction’s behavior), and function URLs follow the same rule.

The *.localhost.overcast.sh wildcard DNS option

Host-routed addressing needs the Host header’s subdomain to actually resolve to wherever Overcast is listening. Three ways to get there, in order of convenience vs. offline-friendliness:

  1. localhost.overcast.sh — recommended. Set OVERCAST_HOSTNAME=localhost.overcast.sh. Every *.localhost.overcast.sh subdomain resolves to 127.0.0.1 through public DNS, so Host-routed URLs work with no hosts-file edits and behave identically on Linux, macOS and Windows. Overcast echoes the domain back in every URL it hands out.

    localhost.localstack.cloud and localhost.floci.io are recognised out of the box and work the same way, so a setup carried over from either tool keeps working — prefer localhost.overcast.sh for anything new.

  2. Plain localhost — the offline fallback. *.localhost resolves to 127.0.0.1 with no network at all on Linux and macOS. It does not on Windows, where only localhost itself is in the hosts file — see the CDK S3 asset-upload troubleshooting in cdk.md. Use this when option 1 is unavailable and you are not on Windows.

  3. A hosts-file entry for each specific subdomain you need, or a local DNS resolver (dnsmasq, *.test via /etc/hosts) that wildcard-resolves your own domain to 127.0.0.1. More setup, but works fully offline, on every OS, and under restrictive network policies.

Caveat: public wildcard DNS needs internet access, and may be blocked.

  • Option 1 needs a DNS lookup to a public resolver, so it will not work in an offline or air-gapped environment. Use option 2 (Linux/macOS only) or option 3 (any OS) there.
  • Some routers, corporate networks, and DNS filtering software implement DNS rebinding protection, which blocks public hostnames from resolving to private/loopback addresses like 127.0.0.1 — exactly what localhost.overcast.sh and localhost.localstack.cloud do on purpose. If Host-routed requests time out or fail to resolve, this is the first thing to check (nslookup localhost.overcast.sh should return 127.0.0.1; if it returns nothing or errors, your network is filtering it).
  • Plain localhost has neither problem, which is why it remains the built-in default and the right choice for offline development on Linux and macOS. A hosts-file entry (option 3) is the fallback that works everywhere, including Windows and behind DNS filtering.

Example: docker compose with a wildcard-DNS hostname

services:
  overcast:
    image: ghcr.io/neaox/overcast
    ports:
      - "4566:4566" # AWS API endpoint
      - "4567:4567" # web management console
    environment:
      OVERCAST_HOSTNAME: localhost.overcast.sh

With this configuration, CreateFunctionUrlConfig returns URLs like http://a1b2c3....lambda-url.us-east-1.localhost.overcast.sh:4566/, which resolve via public DNS to 127.0.0.1 and route straight back into this same container — see performance.md for the matching docker compose pattern for the /data volume.


Which host and port a URL carries, and why

Every URL Overcast hands out follows one rule: the configured OVERCAST_HOSTNAME (when set) on the port you reached Overcast on. Your request is the only proof of a dialable port — Overcast cannot see its own Docker port mapping — so with a remapped port (docker run -p 4652:4566), host-side callers get URLs on :4652 and containers started by Overcast get :4566. Each party receives a URL that works for them. Values that cross the container boundary mechanically — queue URLs baked into function environment by a deploy, invoke payloads — are rewritten at the boundary.

Consequences worth knowing before they look like bugs:

  • The same resource shows different ports to different callers. A stack output read from the host says :4652; the same output inside a Lambda says :4566. Both dial correctly. This is deliberate, not drift.
  • SQS queue URLs echo your exact origin (no hostname substitution): SDKs dial the QueueUrl itself, so Overcast returns precisely what you just proved reachable.
  • The Cognito issuer also carries your port. OIDC discovery requires issuer to match the URL you fetched the configuration from, and jwks_uri must be dialable by whoever validates the token. One consequence with a remapped port: a token minted from the host carries :4652 in its iss, and a validator inside a container comparing that string literally against its own :4566 issuer will report a mismatch. No single port can be dialable from both sides of a remap — if you hit this, publish the API 1:1 (-p 4566:4566) and the issuer becomes identical everywhere. Overcast’s own token validation is unaffected either way.
  • ECR repositoryUri is the exception: it always uses the configured host and port, because the docker daemon — not your API client — is what dials it.
  • All of this presumes OVERCAST_HOSTNAME, if set, resolves to Overcast for every party. The split-horizon names above do. OVERCAST_HOSTNAME=localhost does not (inside a container, localhost is the container) and remains the one configuration that silently breaks container callers.

How this is implemented, and which caller each address is minted for, is in docs/dev/networking.md.


Data-plane endpoints — RDS, and anything else that is a container

Most names Overcast hands back point at Overcast. A few point at a container Overcast started: an RDS instance’s Endpoint.Address, an ElastiCache node’s address. The hostname rule is the same one — the name is minted on the endpoint your request arrived on — but what has to happen for it to resolve is different, and so is the port.

{dbInstanceIdentifier}.{region}.rds.{base}      # RDS DB instance
{dbClusterIdentifier}.cluster.{region}.rds.{base}      # Aurora writer
{dbClusterIdentifier}.cluster-ro.{region}.rds.{base}   # Aurora reader

{base} is OVERCAST_HOSTNAME when set, otherwise the host you called Overcast on — the same precedence every URL follows. With OVERCAST_HOSTNAME=localhost.overcast.sh, a Fn::GetAtt Endpoint.Address comes back as mydb.ap-southeast-2.rds.localhost.overcast.sh, and that is the value a CDK stack bakes into an ECS task definition or a Secrets Manager secret.

How it resolves inside a Lambda or ECS task. Not through Overcast’s DNS server — that one answers “where is Overcast” (see the container-DNS notes). The engine container carries its endpoint name as a Docker network alias on every network emulated compute runs on — the shared data plane (OVERCAST_NETWORK, default overcast), or the VPC network of its DB subnet group when it has one — and Docker’s embedded resolver answers from those aliases before forwarding anything upstream. The alias set covers the name under every hostname Overcast could mint it under, because the name a caller holds depends on the endpoint that caller used.

The port differs by caller, and this one is not cosmetic. The engine listens on 3306/5432 inside the Docker network; on the host it is reachable only through a published port (RDS_PORT_BASE, 33060 upwards, since 3306 is often taken by a local install). So:

CallerEndpoint.AddressEndpoint.Port
Lambda function, ECS task, any sibling containerthe endpoint hostnamethe engine port (3306/5432), as on AWS
The host (CLI, SDK, cdk deploy)the endpoint hostname, or 127.0.0.1 when {base} has no wildcard DNSthe published host port

Aurora’s cluster endpoints follow the same table, because they name the same thing. A cluster has no container of its own: Endpoint and ReaderEndpoint both point at the writer member’s engine, so DescribeDBClusters answers with that instance’s address and port, on the rules above. Both names — not only the writer’s — are registered as aliases on the writer’s container, so cluster.clusterEndpoint.hostname in a CDK stack resolves from inside a task exactly as the instance endpoint does.

That last part is where Overcast diverges from AWS on purpose. On AWS the reader endpoint load-balances across the Aurora Replicas and serves the writer only when the cluster has none. Overcast gives every cluster member its own engine container with its own storage — there is no shared Aurora volume to replicate from — so a reader endpoint spread across the replicas would answer from an empty database. It points at the writer instead: reads are not distributed, but they return the data that was written.

The names themselves drop AWS’s account-specific hash, as every Overcast endpoint name does: AWS’s {cluster}.cluster-{hash}.… and {cluster}.cluster-ro-{hash}.… reduce to the two above. Overcast minted cluster-rw for the writer until 0.0.1-alpha.37 — a label AWS has never used. A cluster created by an older Overcast keeps answering to the name in its stored record, so an upgrade in place does not strand one; a cluster created after it answers only to cluster.

Both pairs connect. Which one you were given is decided by the source address of your request, since a split-horizon hostname is used from both sides of the container boundary and cannot say which side you are on.

One consequence worth knowing before it looks like a bug: a host-side deploy bakes the host-side port into container environment. cdk deploy runs on the host, so Fn::GetAtt Endpoint.Port resolves to the published port, and a task started from that template later reads it from inside the network where only 3306 is open. Applications that take a host and assume the standard port — which is most of them, including the Bitnami images — are unaffected. If you pass the port through explicitly, hard-code the engine’s standard port rather than Endpoint.Port; it is what real AWS would have returned anyway.

When a name does not resolve, check the container first. The alias exists only while the engine container does. docker ps --filter name=overcast-rds- should list it; if it is missing, the instance is available as metadata but has nothing behind it, and the endpoint name resolves nowhere. The instance’s EngineVersion no longer has to be one Overcast advertises — the nearest image family is used and the substitution is logged — so a missing container now means Docker was unavailable or the image could not be pulled.


The Docker networks Overcast uses

Everything Overcast starts as a container shares two Docker networks, and you normally never have to think about either.

NetworkWhat it is for
overcast (OVERCAST_NETWORK)The data plane: where resources reach each other. A Lambda function resolving an RDS endpoint, an ECS task reaching a cache node
overcast_controlOvercast’s own channel to the containers it starts — the Lambda Runtime API, and the AWS_ENDPOINT_URL calls your function and task code make back into the emulator. Derived from OVERCAST_NETWORK; not separately configurable

A resource created in a VPC joins that VPC’s network (overcast-vpc-*) instead of the shared one, so only things in the same VPC can reach it by name — see Lambda, ECS and VPCs for what that costs and how to opt out.

If you attach your own containers to Overcast’s network — a compose service that needs to reach a database Overcast started, say — join overcast.

Lambda, ECS and VPCs

Giving a function a VpcConfig (or a task an awsvpc configuration) puts the container in that VPC: it joins that VPC’s Docker network, takes an address from its CIDR, and reaches the other resources in it by name.

It also takes away everything outside that VPC, which is what naming a VPC means on AWS.

On AWSIn Overcast
A function with a VpcConfig reaching a resource outside that VPC✗ no route✗ refused
A function without a VpcConfig reaching a resource inside one✗ no route✗ refused
Two resources in the same VPC reaching each other
A function in a VPC with no NAT gateway reaching the internet✓ — see what is still not enforced
Security groups restricting any of the above✓ enforced✗ stored, never applied
A function in a VPC calling the AWS APIs without a NAT or VPC endpointdeliberately — see below

“Refused” means what it says. Overcast will not answer a name the caller cannot reach, and the log names both sides:

refusing a data-plane name the caller cannot reach
  name:            mydb.us-east-1.rds.localhost.overcast.sh
  target:          rds mydb
  caller:          lambda api-handler
  target_networks: [overcast-vpc-vpc-0abc]
  caller_networks: [overcast_control overcast]

That is better than AWS gives you, where the same mistake is a connection that times out several minutes later with nothing to point at.

If this just started failing

Your stack is describing something that would not work deployed either. Three ways out, all of them AWS’s own fields rather than Overcast settings — so the fix that works here is the fix that works on AWS:

SituationFix
A function or task should be in the VPCGive it a VpcConfig / awsvpcConfiguration naming a subnet in that VPC
A database should be reachable from outside its VPCPubliclyAccessible: true on the instance
A task should be reachable from outside its VPCassignPublicIp: ENABLED in its awsvpcConfiguration

If none of those is what you want, the honest answer is that the two things genuinely cannot talk on AWS, and the local failure has told you so early.

Two things this does not restrict

Overcast’s own API stays reachable from inside any VPC. A container calls it for S3, SQS, DynamoDB and everything else, and it rides the same channel as the Lambda Runtime API — withholding it would not model a missing NAT gateway, it would stop the function from starting at all. Read it as “every VPC has an interface endpoint for every service”.

On a native Windows or macOS host, nothing is restricted at all. The restriction is only safe where a forbidden connection fails by name, and that needs Overcast’s DNS resolver, which needs /etc/resolv.conf to find upstream servers. There is no such file on those hosts, so the resolver does not start, and rather than let a forbidden connection hang with no explanation Overcast keeps the old permissive behaviour. Run Overcast in a container — the recommended setup — to get the restriction and the diagnostics together.

What is still not enforced

Docker network membership expresses “in this VPC or not”, and nothing finer. So what a VPC lets through is not modelled:

  • Security groups and NACLs. Stored and returned; never applied. There is no port- or source-level filtering between two containers that share a network.
  • Subnets within a VPC. One flat network per VPC — no public/private distinction, no per-subnet routing. Everything in a VPC reaches everything else in it.
  • Internet access. A VPC with no internet gateway is created --internal, but every container also sits on the control plane, which is not — so a private subnet still reaches the internet. Closing that is a separate change with its own risks; see the plan.
  • Two VPCs with the same CIDR, under the default shared strategy, are one Docker network and therefore not isolated from each other. strict and remapped give real separation — see OVERCAST_EC2_VPC_STRATEGY.

“This used to work with LAMBDA_NETWORK set”

Overcast used to create one Docker network per emulator service: overcast_lambda, overcast_ecs, overcast_rds, overcast_elasticache, overcast_msk, overcast_eks and overcast_efs. That partition is gone, and so are the seven environment variables that named those networks. It was the reason a cache node could be reachable from a Lambda function and not from an ECS task (#872) — whether any two things could talk depended on which service happened to bridge the gap.

What to do:

  • You set one of the old variables. They are no longer read. Set OVERCAST_NETWORK instead — one value, for the one network.
  • Your compose file joins overcast_lambda (or another of the seven). Join overcast instead.
  • You have leftover overcast_* networks. Overcast removes them at startup once nothing is attached. One that survives still has a container on it — docker network inspect overcast_lambda names it.

DescribeVpcs returns a VPC I did not create”

Each region now seeds a default VPC on first use, as every real AWS account has. It is marked isDefault, uses AWS’s own 172.31.0.0/16, and is what Vpc.fromLookup(isDefault: true) adopts.

DescribeVpcs honours VpcId.N and the vpc-id and isDefault filters, so a lookup that names what it wants gets one VPC back. If you were relying on an unfiltered list containing exactly your own VPCs, filter it.

You can delete it (DeleteVpc), as on AWS. Overcast will not seed another — also as on AWS, where CreateDefaultVpc is the way back. Its backing network is the shared data plane, so the delete removes the record and leaves the network that every running container is attached to.


See also