Backend DevOps AWS 14 min read

Floci, the No-Strings-Attached LocalStack Alternative: Lambda + SQS + Hono in 80 Lines of Bash

Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

May 12, 2026

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:

FlociLocalStack Community (frozen)LocalStack Pro
Auth tokenNoneRequired since March 2026Required + license
Security updatesActiveFrozenActive
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 URLYesPartialYes
API Gateway HTTP API (v2)YesNoYes
LicenseMITRestrictedCommercial

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

LayerChoice
Monorepopnpm workspaces + Turborepo
Lambda runtimeNode 22 (nodejs22.x), pulled as public.ecr.aws/lambda/nodejs:22 by Floci
HTTP frameworkHono v4 + hono/aws-lambda adapter
Bundleresbuild — single CJS bundle per Lambda
Async messagingSQS + event source mapping, partial-batch failures
Local cloudfloci/floci:latest (MIT-licensed AWS emulator)
Deploypnpm 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 injects AWS_ENDPOINT_URL=http://floci:4566 into every Lambda container it spawns. That means the producer Lambda can talk to SQS at http://floci:4566/000000000000/hello-queue without us hard-coding anything Floci-specific in the app code.
  • networks.floci-net — the spawned Lambda containers join this network, so DNS resolution of floci works inside them.
  • /var/run/docker.sock mount — Floci runs Lambdas as real Docker containers (it pulls public.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:

  1. Idempotent create-or-update for each Lambda
  2. Inject the in-container queue URL into the producer
  3. Create the SQS → consumer mapping (if missing)
  4. 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 on lambda-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:

  1. Drop AWS_ENDPOINT_URL in env.sh so the SDK and CLI hit real AWS regional endpoints.
  2. Use a real IAM role ARN — create a role with AWSLambdaBasicExecutionRole and AWSLambdaSQSQueueExecutionRole, replace arn:aws:iam::000000000000:role/lambda-role.
  3. Use the real queue URL — drop QUEUE_URL_INTERNAL and use the value returned from aws 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 extra create-queue call).
  • 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.

floci localstack aws-lambda sqs cloudwatch hono turborepo pnpm typescript local-development
Hoang Dang Tan Phat (Kane)

Hoang Dang Tan Phat (Kane)

Full-stack developer with 8+ years experience. Building scalable systems with Go, TypeScript, and React.