Skip to content
All posts

· 2 min read

Mirroring the cloud locally with emulators and LocalStack

A docker-compose setup that gives every developer Firestore, Cloud Storage, S3 and SQS on their laptop, with no cloud credentials needed.

  • Docker
  • GCP
  • AWS
  • DX

On both the HM Passport Office’s Lost, Stolen & Recovered passports service (AWS) and the DfT Connectivity Planning Tool (GCP), one decision paid off early: developers never needed real cloud resources to work on the app. Everything the code talks to runs locally in docker-compose, and the app finds it purely through environment variables.

The compose file

services:
  firestore:
    image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators
    command: gcloud emulators firestore start --host-port=0.0.0.0:8080
    ports: ['8080:8080']

  gcs:
    image: fsouza/fake-gcs-server
    command: -scheme http -port 4443 -external-url http://localhost:4443
    ports: ['4443:4443']
    volumes: ['./fixtures/buckets:/data']

  localstack:
    image: localstack/localstack
    ports: ['4566:4566']
    environment:
      SERVICES: s3,sqs,lambda

The fixtures/buckets folder is seeded with sample GeoJSON or XML, so the app starts with realistic data and nobody shares a dev bucket.

Pointing the SDKs at it

The trick is that production code doesn’t change. Only configuration does.

Firestore: the Google client library checks FIRESTORE_EMULATOR_HOST automatically:

FIRESTORE_EMULATOR_HOST=localhost:8080

Cloud Storage: pass an endpoint only when one is configured:

import { Storage } from '@google-cloud/storage';

export const storage = new Storage(
  process.env.GCS_ENDPOINT ? { apiEndpoint: process.env.GCS_ENDPOINT, projectId: 'local' } : {},
);

AWS SDK v3: the same pattern, plus path-style URLs so bucket names don’t need DNS:

import { S3Client } from '@aws-sdk/client-s3';

export const s3 = new S3Client({
  region: process.env.AWS_REGION ?? 'eu-west-2',
  ...(process.env.AWS_ENDPOINT && { endpoint: process.env.AWS_ENDPOINT, forcePathStyle: true }),
});

Why it’s worth the setup

  • Onboarding in minutes. docker compose up, npm run dev, done.
  • Real integration tests. Jest or Playwright suites run against the emulators in CI, exercising actual SDK calls rather than mocks that drift from reality.
  • Safe experiments. Purge a queue, corrupt a bucket, reset in seconds. Nothing shared breaks.
  • Twelve-factor by necessity. If the code can’t run against an emulator, it usually has a hard-coded environment assumption that would hurt later anyway.

Know the limits

Emulators are not the cloud. IAM policies, quotas, eventual-consistency edge cases and managed-service behaviour (IAP, Lambda cold starts) still need a real preview environment. On the HMCTS and Ministry of Justice projects we used per-pull-request preview environments on Kubernetes for exactly that. Local emulators cover the inner loop; preview environments cover the last mile.