Here we will explore what floci does with the AWS services a networked design depends on. I wanted to build the same two services, a retail shop and a pharmacy, three different ways on AWS: behind NAT gateways and IP allowlists, over queues between VPCs, and with IAM identity instead of addresses. The whole point of comparing them is which boundary stops which request, so before writing any CDK I ran single API calls against floci to find out what it enforces and what it only stores. This is part one of two. Part two builds the three versions on top of what I found here, and the code for both is in floci-three-ways.
- What runs for real. Lambda, ECS, ALB, RDS, SQS, EventBridge, CDK deploys.
- What deploys and enforces nothing. WAF, API Gateway
AWS_IAM, resource policies, security group CIDRs. - IAM enforcement mode. Opt-in, action-level, and why a policy naming an exact SQS queue never matches.
- Two gaps I had to design around. Cloud Map DNS and the client IP.
What floci is
floci is an MIT-licensed local AWS emulator, positioned as the replacement for LocalStack Community, which LocalStack sunset in March 2026. It runs as one Docker container on port 4566 and needs no account or token. It mounts the Docker socket and starts real containers for the services that need them, so Lambda functions run in the official public.ecr.aws/lambda runtime images, ECS tasks are ordinary containers, and RDS is a Postgres container with a TCP proxy in front. The services index covers just under a hundred of them. I used v2.0.1.
Setup is one Docker Compose file at the repo root, with an env file per architecture under infra/. The FLOCI_HOSTNAME line is the one to get right, because it decides which hostname floci writes into the service URLs it hands to your code. Left unset, a Lambda gets localhost:4566 as its SQS endpoint, and inside the Lambda container localhost is the Lambda container. Set to the compose service name, the Lambda gets floci:4566, which Docker’s DNS resolves.
services:
floci:
image: floci/floci:latest
ports: ["4566:4566", "7001-7010:7001-7010", "8080-8089:8080-8089"]
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
environment:
FLOCI_HOSTNAME: floci
FLOCI_SERVICES_ECS_DOCKER_NETWORK: ftw-a_default
FLOCI_SERVICES_LAMBDA_DOCKER_NETWORK: ftw-a_default
The extra port ranges are for RDS proxies and ALB listeners, both of which bind on the floci container.
What runs for real
Every row was run with the AWS CLI or CDK against a fresh instance.
| Check | What happened |
|---|---|
Lambda nodejs22.x, create and invoke | Container from the Lambda runtime image, handler output returned |
ECS Fargate task, awsvpc, nginx | Real container, RUNNING in a few seconds |
ALB, ip target group, HTTP API in front | Listener forwarded to the task, 200 through <id>.execute-api.localhost.floci.io:4566. The ALB’s <name>.elb.floci name resolves from the host and from inside a Lambda |
| RDS Postgres 16 | available in about a minute, psql connected on the advertised port |
SQS dead-letter queue, maxReceiveCount 2 | Poison message landed in the DLQ after two receives |
| EventBridge custom bus, rule, SQS target | Delivered |
cdk bootstrap and cdk deploy | VPC with three subnet tiers and a NAT gateway, SQS queue, WAF IP set and Web ACL: 32 resources, CREATE_COMPLETE in two seconds |
Three smaller things also worked. Lambda stdout reached a /aws/lambda/<name> log group with no wiring, start-message-move-task moved the poison message back to its source queue, and an EventBridge archive reached ENABLED with a replay RUNNING, though I did not test delivery of the replayed events.
One snag on CDK: the CLI that npx cdk pulled refused the cloud assembly from aws-cdk-lib 2.268.0. Pinning npx [email protected] fixed it. The skew is between the CDK CLI and aws-cdk-lib, and anyone with a stale global CLI will hit it.
Security groups, WAF and API auth deploy but never block
| Check | What happened |
|---|---|
| WAF Web ACL, default block, one allowed IP, associated to an API stage | Every request still got 200 |
API Gateway REST method with authorizationType: AWS_IAM | Unsigned request got 200, with IAM enforcement mode off and on. Fixed on main the day after my run, see below |
API Gateway resource policy denying all but one aws:SourceIp | 200 after redeploy |
SQS queue policy with Deny on sqs:SendMessage for * | Send succeeded |
| Security group source CIDR | Stored. I did not test it for enforcement; the EC2 doc says rules are not enforced as a firewall, and CIDR-sourced ingress is published on the host through socat sidecars |
| VPC, subnets, EIP, NAT gateway, SG-to-SG ingress | All created and read back. The NAT gateway was available immediately, which on AWS takes minutes |
floci’s WAF doc says this outright: it does not inspect or filter live traffic, the surface exists to validate IaC locally. The same holds for the rest of the table on v2.0.1, with one exception since. Three days after the 2.0.1 tag, and one day after my run, floci merged PR #2895, which makes AWS_IAM methods reject unsigned callers with a 403. It is on main and not in a release as I write this, and the doc for it says the caller is authenticated but execute-api:Invoke is still not evaluated against their policies, so a signed call from any known key goes through. A CDK app with a Web ACL, an AWS_IAM API method and CIDR-scoped security groups deploys cleanly, every resource reads back with the values I set, and the apps behind them answer requests from anyone.
This matters for the retail and pharmacy pair because the first of the three designs relies on exactly those layers. A test can read the WAF IP set back and assert it holds the right CIDR, which is what the make conformance target in the repo does, but it can never watch that IP set reject a request. Nothing in the local run sends a request the Web ACL could refuse.
IAM enforcement mode
IAM enforcement is off by default. FLOCI_SERVICES_IAM_ENFORCEMENT_ENABLED=true turns on a request filter that evaluates identity policies. The default test credential and any unknown access key bypass it, so to see a denial you need an IAM user or an assumed role that floci created.
Once I had created an IAM user and called with its keys, action-level checks worked. A user with no SQS policy got an AccessDeniedException.
$ aws sqs receive-message --queue-url localhost:4566/000000000000/retail-q
An error occurred (AccessDeniedException) when calling the ReceiveMessage operation:
User is not authorized to perform: sqs:ReceiveMessage
I then wrote a policy allowing sqs:SendMessage on the exact queue ARN that floci itself reports, and the send was denied. Widening it to arn:aws:sqs:*:*:retail-q was denied as well. The only two resources that allowed the send were "*" and arn:aws:sqs:eu-west-1:000000000000:*. floci’s own log line, one line in the log and wrapped here, explains it:
IamEnforcementFilter IAM enforcement DENY: akid=AKIA… action=sqs:SendMessage
resource=arn:aws:sqs:eu-west-1:000000000000:*
Every SQS request is resolved to the account-wide wildcard ARN before the policy is evaluated. In the source, ResourceArnBuilder.buildSqsArn reads QueueUrl from the query string, then form parameters, then the JSON body, and falls back to *. AWS CLI v2 and SDK v3 send SQS as JSON, and on this build the JSON path is not reached, so every request from a current client lands on the fallback. Resource-based policies are a separate matter: the IAM doc lists them as not yet supported, and my tests agree.
Enforcement mode answers one question: whether an identity has the action at all. For a local test of least privilege between the retail role and the pharmacy role that is still useful. The cost is that every SQS statement in the CDK apps has to use the account-wide wildcard, with a comment saying it is a floci workaround, because that is not the policy I would ship.
Two gaps I had to design around
Cloud Map DNS does not resolve. create-private-dns-namespace, create-service and register-instance all succeed, but api.pharmacy.local gets ENOTFOUND from a container and from a Lambda. The ALB’s <name>.elb.floci name and the API Gateway’s execute-api.localhost.floci.io name both resolve, because floci’s embedded DNS answers them with its own IP. The Cloud Map doc is consistent with this: the control plane runs in-process and no real DNS records are created. So the retail service has to find the pharmacy through one of those two names, and the Cloud Map namespace is out.
The client IP is always 127.0.0.1. A Lambda behind API Gateway sees requestContext.identity.sourceIp as the address of the Docker port mapping, no matter which container or host made the call. Any app-level IP allowlist has to be tested with a spoofed source, or with the allowlist including 127.0.0.1/32. That app-level allowlist turned out to be the only allowlist in the whole project that floci can be seen rejecting a request on, which is why the first design in part two carries one in its Hono middleware as well as in WAF.
Smaller ones: describe-tasks returns no network interfaces for an ECS task, so the container IP is only visible through docker inspect. And the compose file above puts every Lambda and ECS container on the one Docker network, ftw-a_default, whatever subnets and security groups the task definition names, so subnet isolation between retail and pharmacy is cosmetic on floci.
Which claims the three designs can prove locally
Compute, storage and messaging behave like AWS, and network security is configuration floci stores and reads back without acting on.
The NAT gateway and allowlist version can prove its allowlist only at the application layer, because WAF and security groups never reject anything. Over queues between VPCs, the message flow does run end to end, dead-letter behaviour included, since SQS and EventBridge are real. With IAM identity, retail signs its calls with SigV4 but floci 2.0.1’s API Gateway lets unsigned calls through, so the only IAM claim that version can prove is the action-level one on the calls the enforcement filter sees.
Part two builds all three against the same two services and the same end-to-end scenario, and says for each which of its security claims the local run tested and which it only deployed.