LocalStack Community sunset in March 2026 — auth tokens are now required for the free tier, security updates have stopped, and the inner-loop friction crept up bit by bit. A few of us tried to keep limping along on the pinned image. None of us enjoyed it.
Floci is the no-strings-attached alternative: MIT-licensed, no account, no feature gates, ~13 MiB idle, starts in ~24 ms. It speaks the LocalStack/AWS protocol on port 4566, so swapping the image is genuinely a one-line change in docker-compose.yml.
This post takes a fresh-from-scratch project — a Turborepo monorepo with two Hono Lambdas, an SQS event source mapping between them, and CloudWatch logs — and wires the whole thing up on Floci. The deploy logic fits in ~80 lines of bash because the AWS CLI is doing the work.
By the end you’ll have:
curl ──► Function URL ──► Lambda(producer, Hono) ──┐
│ SendMessage
▼
SQS: hello-queue
│ event source mapping
▼ (batch=5)
Lambda(consumer)
│ console.log
▼
CloudWatch Logs: /aws/lambda/consumer
The full repo is at Phathdt/test-lambda.
Why Floci over the current alternatives?
The frozen LocalStack Community edition technically still runs, but you’re choosing between:
| Floci | LocalStack Community (frozen) | LocalStack Pro | |
|---|---|---|---|
| Auth token | None | Required since March 2026 | Required + license |
| Security updates | Active | Frozen | Active |
| Startup | ~24 ms | ~3.3 s | ~3.3 s |
| Idle memory | ~13 MiB | ~143 MiB | ~143 MiB |
| Image size | ~90 MB | ~1.0 GB | ~1.0 GB |
| Lambda Function URL | Yes | Partial | Yes |
| API Gateway HTTP API (v2) | Yes | No | Yes |
| License | MIT | Restricted | Commercial |
For a personal project or a CI build that doesn’t want to negotiate licenses, Floci is the path of least resistance. The shape of the calls is identical to LocalStack and to real AWS, so the only thing in your scripts that changes is the image tag.
Stack overview
| Layer | Choice |
|---|---|
| Monorepo | pnpm workspaces + Turborepo |
| Lambda runtime | Node 22 (nodejs22.x), pulled as public.ecr.aws/lambda/nodejs:22 by Floci |
| HTTP framework | Hono v4 + hono/aws-lambda adapter |
| Bundler | esbuild — single CJS bundle per Lambda |
| Async messaging | SQS + event source mapping, partial-batch failures |
| Local cloud | floci/floci:latest (MIT-licensed AWS emulator) |
| Deploy | pnpm run + ~80 lines of bash calling the AWS CLI |
Step 1: docker-compose for Floci
# docker-compose.yml
services:
floci:
image: floci/floci:latest
container_name: floci
hostname: floci
ports:
- "4566:4566"
environment:
- FLOCI_DEFAULT_REGION=us-east-1
- FLOCI_HOSTNAME=floci
volumes:
- /var/run/docker.sock:/var/run/docker.sock
user: root
networks:
- floci-net
networks:
floci-net:
name: floci-net
Three lines worth their own callout:
FLOCI_HOSTNAME=floci— Floci injectsAWS_ENDPOINT_URL=http://floci:4566into every Lambda container it spawns. That means the producer Lambda can talk to SQS athttp://floci:4566/000000000000/hello-queuewithout us hard-coding anything Floci-specific in the app code.networks.floci-net— the spawned Lambda containers join this network, so DNS resolution offlociworks inside them./var/run/docker.sockmount — Floci runs Lambdas as real Docker containers (it pullspublic.ecr.aws/lambda/nodejs:22, which is the same image AWS uses). Mounting the host socket lets Floci spawn them.
Up:
docker compose up -d
curl http://localhost:4566/_floci/health
You should see a JSON blob listing every service Floci has running. SQS, Lambda, and logs are the ones we care about today.
Step 2: Project layout
Two Hono-style Lambdas (one HTTP, one SQS-triggered), one shared package, a handful of bash scripts:
.
├── apps/
│ ├── producer/ # Hono Lambda — GET /hello/:name → SendMessage to SQS
│ └── consumer/ # SQS-triggered Lambda — logs structured JSON
├── packages/
│ └── shared/ # Shared types + queue name constant
├── scripts/
│ ├── env.sh # AWS endpoint + creds + names
│ ├── setup-queue.sh
│ ├── deploy.sh
│ ├── invoke-producer.sh
│ └── tail-logs.sh
├── docker-compose.yml
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
pnpm-workspace.yaml is the standard two-liner:
packages:
- "apps/*"
- "packages/*"
Step 3: The shared package
Both Lambdas agree on the queue name and the message shape:
// packages/shared/src/index.ts
export const QUEUE_NAME = 'hello-queue';
export interface HelloMessage {
greeting: string;
name: string;
emittedAt: string;
source: 'producer';
}
Producer and consumer reference it via workspace:*. esbuild bundles it inline at build time — no separate tsc pass, no .d.ts shipping.
Step 4: The producer Lambda
A 30-line Hono app with two routes. The interesting one is /hello/:name, which builds a HelloMessage and SendMessages it to SQS.
// apps/producer/src/index.ts
import { Hono } from 'hono';
import { handle } from 'hono/aws-lambda';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { QUEUE_NAME, type HelloMessage } from '@app/shared';
const region = process.env.AWS_REGION ?? 'us-east-1';
const endpoint = process.env.AWS_ENDPOINT_URL;
const queueUrl =
process.env.QUEUE_URL ?? `${endpoint ?? 'http://floci:4566'}/000000000000/${QUEUE_NAME}`;
const sqs = new SQSClient({ region, endpoint });
const app = new Hono();
app.get('/', (c) =>
c.json({ ok: true, service: 'producer', queueUrl, endpoint: endpoint ?? null }),
);
app.get('/hello/:name', async (c) => {
const name = c.req.param('name');
const payload: HelloMessage = {
greeting: `Hello, ${name}!`,
name,
emittedAt: new Date().toISOString(),
source: 'producer',
};
const out = await sqs.send(
new SendMessageCommand({ QueueUrl: queueUrl, MessageBody: JSON.stringify(payload) }),
);
return c.json({ enqueued: payload, messageId: out.MessageId });
});
export const handler = handle(app);
A subtle thing: we don’t pass endpoint: 'http://floci:4566' directly in the SDK config — we let Floci’s injected AWS_ENDPOINT_URL flow through process.env. That way the same code runs on real AWS without modification (where AWS_ENDPOINT_URL is unset and the SDK hits AWS regionally).
Step 5: The consumer Lambda
The consumer’s job is dead-simple: parse the SQS body, log a structured line, and report per-record failures so the SQS runtime knows which messages to redrive.
// apps/consumer/src/index.ts
import type { SQSEvent, SQSBatchResponse, SQSBatchItemFailure } from 'aws-lambda';
import type { HelloMessage } from '@app/shared';
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const batchItemFailures: SQSBatchItemFailure[] = [];
for (const record of event.Records) {
try {
const msg = JSON.parse(record.body) as HelloMessage;
console.log(
JSON.stringify({
level: 'info',
event: 'message-processed',
messageId: record.messageId,
greeting: msg.greeting,
name: msg.name,
emittedAt: msg.emittedAt,
receivedAt: new Date().toISOString(),
}),
);
} catch (err) {
console.error('failed to process', record.messageId, err);
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
return { batchItemFailures };
};
The batchItemFailures pattern is the only sane way to handle partial failures: one poisoned message in a batch of five shouldn’t force the other four back onto the queue.
Step 6: Bundle each Lambda with esbuild
esbuild on each workspace, one command per app:
// apps/producer/package.json
{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/index.js",
"package": "pnpm run build && cd dist && zip -rq ../function.zip index.js"
}
}
The consumer is identical. The producer bundle ends up around 1.2 MB (the AWS SDK v3 SQS client is the bulk of it); the consumer is 1.7 KB. Both well under the 50 MB Lambda zip limit.
Turbo’s job is just to fan these out:
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
"package": { "dependsOn": ["build"], "outputs": ["function.zip"] }
}
}
pnpm run package
# turbo fans out to both apps in parallel:
# @app/producer:package → dist/index.js (1.2mb) → function.zip
# @app/consumer:package → dist/index.js (1.7kb) → function.zip
Step 7: A single env file for all the bash scripts
This is the one bit of boilerplate the AWS CLI demands. Source it from every script:
# scripts/env.sh
export AWS_ENDPOINT_URL=http://localhost:4566
export AWS_DEFAULT_REGION=us-east-1
export AWS_ACCESS_KEY_ID=test
export AWS_SECRET_ACCESS_KEY=test
export AWS_PAGER=""
export ACCOUNT_ID=000000000000
export QUEUE_NAME=hello-queue
export PRODUCER_FN=producer
export CONSUMER_FN=consumer
export QUEUE_ARN="arn:aws:sqs:us-east-1:${ACCOUNT_ID}:${QUEUE_NAME}"
# URL used inside Lambda containers to reach Floci over the shared Docker network.
export QUEUE_URL_INTERNAL="http://floci:4566/${ACCOUNT_ID}/${QUEUE_NAME}"
export ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/lambda-role"
Two URLs again — the same trap as LocalStack:
- From your laptop:
http://localhost:4566/000000000000/hello-queue - From inside the producer Lambda container:
http://floci:4566/000000000000/hello-queue
The script that creates the producer sets QUEUE_URL=$QUEUE_URL_INTERNAL in the function’s environment so it points at the second form.
Step 8: Create the SQS queue
# scripts/setup-queue.sh
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/env.sh"
aws sqs create-queue --queue-name "$QUEUE_NAME" >/dev/null 2>&1 || true
aws sqs get-queue-url --queue-name "$QUEUE_NAME"
echo "QueueArn: $QUEUE_ARN"
pnpm run setup
# NOTE: `pnpm setup` is a reserved built-in command (sets PNPM_HOME),
# so you MUST use `pnpm run setup` to invoke our script.
That gotcha cost me five minutes the first time. If you forget the run, pnpm helpfully tries to install itself into your shell profile and you wonder why no queue ever shows up.
Step 9: Deploy both Lambdas + the SQS event source mapping
This is the centerpiece — and at ~50 lines it’s all the deploy code you need.
# scripts/deploy.sh
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/env.sh"
deploy_fn() {
local name="$1"
local zip_path="$2"
local env_vars="$3"
if aws lambda get-function --function-name "$name" >/dev/null 2>&1; then
echo "==> Updating $name code..."
aws lambda update-function-code \
--function-name "$name" \
--zip-file "fileb://$zip_path" >/dev/null
aws lambda update-function-configuration \
--function-name "$name" \
--environment "Variables={$env_vars}" >/dev/null
else
echo "==> Creating $name..."
aws lambda create-function \
--function-name "$name" \
--runtime nodejs22.x \
--role "$ROLE_ARN" \
--handler index.handler \
--zip-file "fileb://$zip_path" \
--environment "Variables={$env_vars}" >/dev/null
fi
}
deploy_fn "$PRODUCER_FN" "apps/producer/function.zip" "QUEUE_URL=$QUEUE_URL_INTERNAL"
deploy_fn "$CONSUMER_FN" "apps/consumer/function.zip" "LOG_LEVEL=info"
echo "==> Wiring SQS → consumer event source mapping..."
EXISTING=$(aws lambda list-event-source-mappings \
--function-name "$CONSUMER_FN" \
--query "EventSourceMappings[?EventSourceArn=='$QUEUE_ARN'].UUID" \
--output text 2>/dev/null || true)
if [ -z "$EXISTING" ] || [ "$EXISTING" = "None" ]; then
aws lambda create-event-source-mapping \
--function-name "$CONSUMER_FN" \
--event-source-arn "$QUEUE_ARN" \
--batch-size 5 >/dev/null
fi
aws lambda create-function-url-config \
--function-name "$PRODUCER_FN" \
--auth-type NONE >/dev/null 2>&1 || echo "(URL already exists)"
That’s the full deploy pipeline:
- Idempotent create-or-update for each Lambda
- Inject the in-container queue URL into the producer
- Create the SQS → consumer mapping (if missing)
- Attach a Function URL to the producer
There’s no IAM dance — Floci accepts the fake lambda-role ARN. On real AWS you’d swap in a role with AWSLambdaBasicExecutionRole and AWSLambdaSQSQueueExecutionRole.
pnpm run deploy
# turbo packages both apps, then bash deploys them.
# Total: ~3 seconds on a warm cache.
Step 10: The Function URL Host-header trick
Here’s the Floci-specific wrinkle that bit me, and that the localstack/_user_request_ convention spared us before.
When Floci creates a Lambda Function URL, it returns a real-shaped AWS URL:
aws lambda get-function-url-config --function-name producer \
--query 'FunctionUrl' --output text
# → http://21e2cc548d48313c931ea30d5d98f890.lambda-url.us-east-1.floci:4566/
That hostname *.lambda-url.us-east-1.floci only resolves inside the floci-net Docker network. From your laptop, DNS won’t find it. Curling that URL straight gives Could not resolve host.
The fix: route the request to localhost:4566 and pass the real hostname as a Host: header. Floci uses the header to route to the right Function URL:
# scripts/invoke-producer.sh
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/env.sh"
NAME="${1:-world}"
FN_URL=$(aws lambda get-function-url-config \
--function-name "$PRODUCER_FN" \
--query 'FunctionUrl' --output text)
URL_HOST=$(echo "$FN_URL" | sed -E 's|http://([^/]+)/.*|\1|' | sed -E 's|:4566$||')
curl -sS -H "Host: $URL_HOST" "http://localhost:4566/hello/$NAME"
pnpm run invoke alice
# ==> POST via Host: 21e2cc548d48313c931ea30d5d98f890.lambda-url.us-east-1.floci → /hello/alice
# {"enqueued":{"greeting":"Hello, alice!","name":"alice","emittedAt":"…","source":"producer"},
# "messageId":"df0ed399-cf08-4e22-9860-b77d7719965b"}
Comparison: LocalStack used a hand-rolled URL convention (
/restapis/<id>/local/_user_request_/...). Floci sticks closer to the real AWS shape (random subdomain onlambda-url.<region>.<...>). The Host-header workaround is a small price for that fidelity — when you deploy to real AWS, the exact same Function URL just works without the header trick.
Step 11: Watch the consumer’s CloudWatch logs
Unlike LocalStack Community, Floci does emit CloudWatch logs for Lambda invocations. They land in /aws/lambda/<fn-name> like the real thing.
# scripts/tail-logs.sh
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/env.sh"
GROUP="/aws/lambda/${CONSUMER_FN}"
STREAM=$(aws logs describe-log-streams \
--log-group-name "$GROUP" \
--order-by LastEventTime --descending \
--max-items 1 \
--query 'logStreams[0].logStreamName' --output text 2>/dev/null || true)
if [ -z "$STREAM" ] || [ "$STREAM" = "None" ]; then
echo "no streams yet — invoke the producer first"
exit 0
fi
aws logs get-log-events \
--log-group-name "$GROUP" \
--log-stream-name "$STREAM" \
--limit 50 \
--query 'events[*].[timestamp,message]' \
--output text
pnpm run logs
# ==> stream: 2026/05/12/[$LATEST]e4f5b714
# 1778555423309 … 3457cb08-… INFO {"level":"info","event":"message-processed",
# "messageId":"df0ed399-…","greeting":"Hello, alice!","name":"alice", …}
The messageId matches what the producer returned — that’s the round-trip confirmation: producer Lambda → SQS → consumer Lambda → CloudWatch, end-to-end inside one Docker container with no AWS account involved.
aws logs tail works too:
source scripts/env.sh
aws logs tail /aws/lambda/consumer --follow --since 5m
End-to-end smoke test
pnpm install
pnpm floci:up
pnpm run setup
pnpm run deploy
pnpm run invoke alice
pnpm run logs
pnpm floci:down
From a clean checkout that runs in well under a minute (most of which is pnpm install and the first Floci pull).
Going to real AWS
The script needs three small changes:
- Drop
AWS_ENDPOINT_URLinenv.shso the SDK and CLI hit real AWS regional endpoints. - Use a real IAM role ARN — create a role with
AWSLambdaBasicExecutionRoleandAWSLambdaSQSQueueExecutionRole, replacearn:aws:iam::000000000000:role/lambda-role. - Use the real queue URL — drop
QUEUE_URL_INTERNALand use the value returned fromaws sqs get-queue-url(which on real AWS resolves the same from inside and outside the Lambda).
Everything else — Function URL creation, event source mapping, the deploy script structure — is identical.
For production you’d also want:
- DLQ on the SQS queue with
maxReceiveCount=3(one extracreate-queuecall). - CloudWatch alarm on the DLQ depth — your “something is stuck” pager.
- Reserved concurrency on the consumer if downstream services have rate limits.
- A real role-per-function instead of one shared role.
But the LocalStack/Floci version exercises the same control plane, so the bugs you’d hit in prod are the same bugs you’ll hit here.
Why bash, not a TypeScript deploy script?
I keep oscillating between the two. The TypeScript version reads nicer, has types on every SDK call, and integrates with Vitest. The bash version is shorter, has no node_modules to install in CI, and the AWS CLI’s error messages are clearer than the SDK’s wrapped exceptions.
For this size of project, bash wins. The whole scripts/ directory is 80 lines. If it grew past ~300, I’d port it to TypeScript with @aws-sdk/client-{lambda,sqs,cloudwatch-logs} — the previous post went that route for a larger stack.
Recap
We built a complete serverless flow on a free, open-source AWS emulator:
- Floci replacing LocalStack Community — same port, same API shape, MIT-licensed
- Turborepo + pnpm workspaces for two-Lambda parallel builds
- esbuild bundling each Lambda to a single CJS file (1.7 KB for the consumer)
- SQS event source mapping with partial-batch failure reporting
- Lambda Function URL with the
Host:header trick for cross-network access from the host - CloudWatch logs via
/aws/lambda/<fn-name>— yes, they actually work - ~80 lines of bash for the entire deploy + invoke + log pipeline
Full reference repo: Phathdt/test-lambda.
If you’ve been putting off migrating off LocalStack Community because the alternatives looked heavyweight, Floci really is docker compose up and one image-tag change. The rest of your scripts don’t notice.