Local Development Environment
The Accessible PDF project runs across three clouds β Cloudflare (Workers, R2, KV), AWS (Lambda, SQS, DynamoDB, SSM, EC2), and Supabase (Postgres, Auth). The local development environment emulates all of these services in Docker so you can run the entire conversion pipeline end-to-end without internet access or cloud credentials.
Table of Contents
- Quick Start
- How It Works
- Service Reference
- Using the Environment
- How Each Cloud Service Is Emulated
- Docker Compose Profiles
- Files and Directory Layout
- Working with the Database
- Working with Object Storage
- Working with AWS Services
- Testing Workflows
- Day-to-Day Commands
- Troubleshooting
Quick Start
Prerequisites
- Docker Desktop 24+ with at least 8 GB RAM allocated (Settings β Resources)
- Node.js 22+ (via nvm or direct install)
- aws CLI v2 (optional β used by seeding scripts and for inspecting LocalStack)
One-Command Setup
./scripts/setup-local.shThis script:
- Verifies Docker and Node.js are installed and running
- Copies
.env.local.exampleβ.env.node-server(pre-filled for local stack) - Creates
.env.grafanawith a default admin password - Creates a
vertex-ai.jsonplaceholder (required by API node Docker mount) - Runs
npm install - Starts all services via
docker compose --profile local up -d --build - Waits for each service to pass its health check
- Prints a table of service URLs and test credentials
After setup, start the web frontend in a separate terminal:
# First time only: configure the web app for local endpoints# In apps/web/.env.local, uncomment the local dev lines:# NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000# NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbG... (the anon key from .env.local.example)# NEXT_PUBLIC_API_URL=http://localhost:8800# NEXT_PUBLIC_DEV_AUTO_LOGIN_PASSWORD=localdev123!
npm run dev --filter=webTest Accounts
These are created automatically by supabase/seed.sql:
| Password | Role | Credits | |
|---|---|---|---|
[email protected] | localdev123! | Standard user | 100 |
[email protected] | localadmin123! | Admin | 1000 |
How It Works
Architecture Overview
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ Your Machine ββ ββ npm run dev --filter=web βββ Next.js on localhost:3000 ββ β ββ β auth (JWT) ββββββββββββββββββββββββββββββββ ββ ββββββββββββββββββββββββ Supabase (Docker) β ββ β β ββ Postgres :54322 β ββ β β ββ GoTrue Auth :8000 β ββ β β ββ PostgREST :8000 β ββ β β ββ Kong :8000 β ββ β β ββ Studio :54323 β ββ β β ββ Inbucket :54324 β ββ β ββββββββββββββββββββββββββββββββ ββ β ββ β API calls ββββββββββββββββββββββββββββββββ ββ ββββββββββββββββββββββββ Traefik LB :8800 β ββ β ββ API Node 1 :8790 β ββ β ββ API Node 2 :8790 β ββ ββββββββββββ¬ββββββββββββββββββββ ββ β ββ ββββββββββββββββββββββββββββΌββββββββββββββββββββ ββ β β β ββ βΌ βΌ βΌ ββ ββββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββ ββ β MinIO :9000β β LocalStack :4566β β WeasyPrint β ββ β (S3-compat) β β ββ SQS β β :5001β ββ β Console :9001β β ββ DynamoDB β βββββββββββββββ€ ββ β β β ββ SSM β β Audiveris β ββ β 6 buckets β β ββ SES β β :5002β ββ ββββββββββββββββββββ ββββββββββ¬ββββββββββ βββββββββββββββ ββ β ββ βΌ ββ ββββββββββββββββββββββββ ββ β Batch Worker β ββ β (SQS consumer) β ββ ββββββββββββββββββββββββ ββ ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββ β Monitoring (Loki + Grafana + Promtail) :3000 β ββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββRequest Flow (PDF Conversion)
- User uploads a PDF via the Next.js web app at
localhost:3000 - Frontend authenticates with Supabase Auth (GoTrue) at
localhost:8000β receives a JWT - Frontend calls the API at
localhost:8800with the JWT in theAuthorizationheader - Traefik load-balances the request to one of two API Node containers
- API Node validates the JWT against the local Supabase JWT secret
- File is stored in MinIO (S3-compatible) at
minio:9000in theaccessible-pdf-filesbucket - File metadata is written to Supabase Postgres
- Credits are deducted via the
deduct_creditsSupabase RPC function - A pipeline message is enqueued to LocalStack SQS
- Batch Worker picks up the SQS message, downloads the PDF from MinIO, runs the conversion pipeline (Puppeteer for rendering, AI APIs for OCR/description if keys are present)
- Results are written back to MinIO and progress is tracked in LocalStack DynamoDB
- Frontend polls for completion and displays the converted document
What Cannot Be Emulated
AI/LLM APIs (Anthropic Claude, Google Gemini, Mistral, OpenAI, Mathpix) still require real API keys. Add your keys to .env.node-server for full pipeline functionality. If keys are missing, the pipeline gracefully skips those backends β youβll get partial results but no crashes.
Cloudflare Browser Rendering is a production-only Cloudflare binding. The Node server mode uses native Puppeteer (Chrome) instead, which is functionally equivalent and already included in the API Node Docker image.
Service Reference
Ports
| Port | Service | Purpose |
|---|---|---|
| 3000 | Grafana | Monitoring dashboards (bound to 127.0.0.1) |
| 4566 | LocalStack | AWS service emulation (SQS, DynamoDB, SSM) |
| 5001 | WeasyPrint | HTML-to-PDF engine (internal, Docker network) |
| 5002 | Audiveris | Music notation OCR (internal, Docker network) |
| 8000 | Supabase Kong | API gateway (auth, REST, meta) |
| 8800 | Traefik | Load balancer β API nodes |
| 8801 | Traefik | Admin dashboard |
| 9000 | MinIO | S3-compatible API |
| 9001 | MinIO | Web console |
| 54322 | Postgres | Direct database access |
| 54323 | Supabase Studio | Database GUI |
| 54324 | Inbucket | Email capture (magic links) |
Docker Containers
| Container | Image | Profile |
|---|---|---|
accessible-pdf-converter-api-node-1 | workers/api/Dockerfile | (default) |
accessible-pdf-converter-api-node-2 | workers/api/Dockerfile | (default) |
accessible-pdf-weasyprint | services/weasyprint/Dockerfile | (default) |
accessible-pdf-audiveris | services/audiveris/Dockerfile | (default) |
accessible-pdf-traefik | traefik:v3 | (default) |
accessible-pdf-supabase-db | supabase/postgres:15.6.1.145 | local |
accessible-pdf-supabase-auth | supabase/gotrue:v2.158.1 | local |
accessible-pdf-supabase-rest | postgrest/postgrest:v12.2.3 | local |
accessible-pdf-supabase-kong | kong:3.5 | local |
accessible-pdf-supabase-meta | supabase/postgres-meta:v0.83.2 | local |
accessible-pdf-supabase-studio | supabase/studio:20241202 | local |
accessible-pdf-supabase-inbucket | inbucket/inbucket:3.0 | local |
accessible-pdf-supabase-migrate | postgres:15 (init) | local |
accessible-pdf-minio | minio/minio:latest | local |
accessible-pdf-minio-init | minio/mc (init) | local |
accessible-pdf-localstack | localstack/localstack:latest | local |
accessible-pdf-batch-worker | workers/api/Dockerfile | local |
accessible-pdf-loki | grafana/loki:3.4.2 | monitoring, local |
accessible-pdf-grafana | grafana/grafana:11.5.2 | monitoring, local |
accessible-pdf-promtail | grafana/promtail:3.4.2 | monitoring, local |
Using the Environment
Starting
# Full local stack (recommended)docker compose --profile local up -d
# Or use the setup script (first time)./scripts/setup-local.shStopping
# Stop containers (preserves data volumes)docker compose --profile local down
# Stop and delete all data (full reset)docker compose --profile local down -vChecking Health
./scripts/local-healthcheck.shThis checks every HTTP endpoint and Docker container, printing a pass/fail report.
Viewing Logs
# All servicesdocker compose --profile local logs -f
# Specific servicedocker compose --profile local logs -f api-node-1
# Batch workerdocker compose --profile local logs -f batch-worker
# Supabase migrations (init container β shows once)docker compose --profile local logs supabase-migrateRebuilding After Code Changes
# Rebuild only the API node containersdocker compose --profile local up -d --build api-node-1 api-node-2
# Rebuild the batch workerdocker compose --profile local up -d --build batch-workerHow Each Cloud Service Is Emulated
Supabase β Local Postgres + GoTrue
Production: Hosted Supabase at vuvwmfxssjosfphzpzim.supabase.co β managed Postgres, managed auth, managed REST API.
Local: Six Docker containers replicate the Supabase stack:
supabase-dbβ Postgres 15 with the official Supabase image (includes auth schema, extensions, roles). All 57 migrations fromsupabase/migrations/are applied on first boot by thesupabase-migrateinit container.supabase-auth(GoTrue) β handles signups, logins, JWT issuance, and magic links. Configured withGOTRUE_MAILER_AUTOCONFIRM=trueso no email confirmation is needed. SMTP goes to Inbucket.supabase-rest(PostgREST) β auto-generates a REST API from the Postgres schema, respecting RLS policies.supabase-kongβ API gateway that routes/auth/v1/*to GoTrue and/rest/v1/*to PostgREST. Handles API key validation (anon vs service_role).supabase-studioβ web-based database GUI atlocalhost:54323for inspecting tables, running queries, and managing auth users.supabase-inbucketβ catches all outgoing emails (magic links, password resets) so they never leave your machine. View captured emails atlocalhost:54324.
JWT keys: The local stack uses the standard supabase-demo JWT secret and pre-generated anon/service_role keys. These are the same keys used by the official Supabase CLI local development setup, making them safe for local-only use.
How API nodes connect: The API nodeβs .env.node-server has SUPABASE_URL=http://supabase-kong:8000 β all Supabase access goes through Kong on the Docker network. The SUPABASE_JWT_SECRET matches GoTrueβs GOTRUE_JWT_SECRET, so JWT validation works identically to production.
Cloudflare R2 β MinIO
Production: Five R2 buckets accessed via S3-compatible API (from Node server) or native R2 bindings (from Cloudflare Worker).
Local: MinIO provides an S3-compatible API on port 9000. The minio-init container creates six buckets on startup:
| Bucket | Replaces (Production) |
|---|---|
accessible-pdf-files | R2: accessible-pdf-files |
accessible-photos | R2: accessible-photos |
accessible-forms | R2: accessible-forms |
org-chart-files | R2: org-chart-files |
convert-email-files | R2: convert-email-files |
accessible-pdf-email | AWS S3: email intake bucket |
No code changes needed: The API node already uses S3ObjectStorage (AWS SDK v3 S3Client) with a configurable S3_ENDPOINT. Setting S3_ENDPOINT=http://minio:9000 redirects all storage operations to MinIO transparently.
MinIO Console: Browse buckets and objects at http://localhost:9001 (login: minioadmin / minioadmin).
Cloudflare KV β In-Memory Map
Production: The Node server calls the Cloudflare KV REST API (api.cloudflare.com/client/v4/accounts/.../storage/kv/...) for session storage and rate limiting. This requires CF_ACCOUNT_ID and CF_API_TOKEN.
Local: When ENVIRONMENT=local or KV_MODE=memory is set, the server uses InMemoryKvStorage β a Map<string, { value, expiresAt }> with lazy TTL expiry. This is defined in workers/api/src/providers/local.ts.
How itβs wired: In server.ts, the isLocalMode flag causes buildSyntheticEnv() to use createLocalKvNamespaceShim() instead of createKvNamespaceShim(). Similarly, createNodeServerStorageContext() in node-server.ts detects missing CF credentials and falls back to InMemoryKvStorage.
Trade-off: KV data is ephemeral β it resets when the container restarts. This is fine for local dev since sessions and rate limits are transient. In production, KV persists across requests via Cloudflareβs global network.
AWS SQS β LocalStack SQS
Production: Two SQS queues provisioned by CDK (queue-stack.ts):
accessible-pdf-pipelineβ main work queue (900s visibility, 20s long polling, 3 retries)accessible-pdf-pipeline-dlqβ dead letter queue (14-day retention)
Local: The infra/localstack/init-aws.sh script creates both queues with identical settings on LocalStack startup. The batch workerβs SQS_QUEUE_URL environment variable points to the LocalStack endpoint.
AWS DynamoDB β LocalStack DynamoDB
Production: Single-table design (accessible-pdf-{env}-data) with pk/sk keys, TTL, and PAY_PER_REQUEST billing.
Local: Created by init-aws.sh with the same schema. The batch worker uses the AWS SDK which respects AWS_ENDPOINT_URL=http://localstack:4566.
AWS SSM Parameter Store β LocalStack SSM
Production: API keys and secrets stored at /accessible-pdf/{env}/* in AWS SSM. Lambda and batch workers load these at cold start.
Local: init-aws.sh pre-populates SSM parameters at /accessible-pdf/local/* with local service URLs and placeholder values. The batch workerβs SSM_PREFIX=/accessible-pdf/local causes it to read from LocalStack instead of AWS.
EC2 Batch Workers β Docker Container
Production: EC2 Auto Scaling Group with spot instances, scaling based on SQS queue depth. Workers pull Docker images from ECR.
Local: A single batch-worker Docker container runs the same code (workers/batch/src/index.ts) with environment variables pointed at LocalStack and MinIO. The SpotMonitor (which polls EC2 instance metadata at 169.254.169.254) is disabled when ENVIRONMENT=local to avoid useless HTTP errors.
AWS Lambda β Direct Execution
Production: Two Lambda functions β API (Hono app) and email-intake (SES processor).
Local: Lambda is not deployed to LocalStack (Docker-in-Docker cold starts are too slow for iterative development). Instead:
- API Lambda: The same Hono app runs directly as a Node.js server in the API node containers β functionally identical.
- Email-intake Lambda: Test directly with
npx tsxand a synthetic SES event (see Testing Workflows).
Docker Compose Profiles
The docker-compose.yml uses profiles to let you start only what you need:
| Profile | Command | What Starts |
|---|---|---|
| (none) | docker compose up | Core: Traefik, API Node x2, WeasyPrint, Audiveris |
local | docker compose --profile local up | Core + Supabase + MinIO + LocalStack + Batch Worker + Monitoring |
monitoring | docker compose --profile monitoring up | Loki, Grafana, Promtail |
The default profile (no flag) starts only the core services β useful when youβre developing against production Supabase and Cloudflare with real credentials in .env.node-server.
Files and Directory Layout
Configuration Files
| File | Purpose |
|---|---|
docker-compose.yml | All service definitions with profile annotations |
.env.local.example | Pre-filled env for local stack β copy to .env.node-server |
.env.node-server.example | Production-oriented env template |
infra/supabase/kong.yml | Kong declarative config (routes, consumers, API keys) |
infra/supabase/init-db.sh | Migration runner (applies supabase/migrations/*.sql in order) |
infra/localstack/init-aws.sh | Creates SQS queues, DynamoDB table, SSM parameters |
supabase/seed.sql | Test users and initial data |
Source Code (Local Dev Support)
| File | What Changed |
|---|---|
workers/api/src/providers/local.ts | InMemoryKvStorage class and createLocalKvNamespaceShim() |
workers/api/src/providers/node-server.ts | Falls back to InMemoryKvStorage when CF credentials are absent |
workers/api/src/server.ts | isLocalMode flag: CF credentials optional, in-memory KV |
workers/batch/src/index.ts | Skips SpotMonitor.start() when ENVIRONMENT=local |
Scripts
| Script | Purpose |
|---|---|
scripts/setup-local.sh | One-command bootstrap (env files, npm install, docker compose) |
scripts/local-healthcheck.sh | Checks all HTTP endpoints and Docker containers |
scripts/seed-local.sh | Uploads sample data to MinIO, verifies LocalStack and Supabase |
Working with the Database
Supabase Studio
Open http://localhost:54323 to browse tables, run SQL, and manage auth users. No login required.
Direct Postgres Access
psql -h localhost -p 54322 -U supabase_admin -d postgresPassword: postgres
Applying New Migrations
- Create a migration file:
supabase/migrations/YYYYMMDD_NNN_description.sql - Restart the migrate container:
Terminal window docker compose --profile local restart supabase-migrate - Or restart the whole stack:
Terminal window docker compose --profile local down && docker compose --profile local up -d
Using Supabase CLI for Migration Diffs
If you prefer supabase db diff for generating migrations:
supabase start # Starts its own Docker containers (separate from compose)supabase db diff -f my_migration --use-migrasupabase stopNote: supabase start runs independently from the compose stack. Use one or the other, not both simultaneously.
Working with Object Storage
MinIO Console
Open http://localhost:9001 and log in with minioadmin / minioadmin. You can browse buckets, upload files, and inspect object metadata.
AWS CLI with MinIO
# List bucketsAWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \ aws --endpoint-url http://localhost:9000 s3 ls
# Upload a fileAWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \ aws --endpoint-url http://localhost:9000 s3 cp test.pdf s3://accessible-pdf-files/test.pdf
# List objects in a bucketAWS_ACCESS_KEY_ID=minioadmin AWS_SECRET_ACCESS_KEY=minioadmin \ aws --endpoint-url http://localhost:9000 s3 ls s3://accessible-pdf-files/Working with AWS Services
All AWS CLI commands work against LocalStack by adding --endpoint-url http://localhost:4566.
SQS
export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test
# List queuesaws --endpoint-url http://localhost:4566 --region us-east-1 sqs list-queues
# Check queue depthaws --endpoint-url http://localhost:4566 --region us-east-1 sqs get-queue-attributes \ --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/accessible-pdf-pipeline \ --attribute-names ApproximateNumberOfMessages
# Send a test messageaws --endpoint-url http://localhost:4566 --region us-east-1 sqs send-message \ --queue-url http://sqs.us-east-1.localhost.localstack.cloud:4566/000000000000/accessible-pdf-pipeline \ --message-body '{"test": true}'DynamoDB
# Scan the tableaws --endpoint-url http://localhost:4566 --region us-east-1 dynamodb scan \ --table-name accessible-pdf-data
# Get a specific itemaws --endpoint-url http://localhost:4566 --region us-east-1 dynamodb get-item \ --table-name accessible-pdf-data \ --key '{"pk": {"S": "session#123"}, "sk": {"S": "meta"}}'SSM
# List all local parametersaws --endpoint-url http://localhost:4566 --region us-east-1 ssm get-parameters-by-path \ --path /accessible-pdf/local --with-decryptionTesting Workflows
Full PDF Conversion Pipeline
- Start the stack and web app:
Terminal window docker compose --profile local up -dnpm run dev --filter=web - Open
http://localhost:3000and sign in as[email protected]/localdev123! - Upload a PDF
- Watch processing in real-time:
Terminal window # API node logs (file upload, metadata)docker compose --profile local logs -f api-node-1# Batch worker logs (SQS consumption, conversion)docker compose --profile local logs -f batch-worker - Verify results:
- MinIO Console (
localhost:9001) β check for output files - Supabase Studio (
localhost:54323) β check file metadata and credit deductions - Grafana (
localhost:3000) β view structured logs
- MinIO Console (
Email Intake Lambda
The email-intake Lambda isnβt deployed to LocalStack. Test it directly:
cd workers/email-intakenpx tsx -e " import { handler } from './src/index.js'; const event = { Records: [{ ses: { mail: { messageId: 'test-123', source: '[email protected]', commonHeaders: { subject: 'Convert this PDF' } }, receipt: { action: { bucketName: 'accessible-pdf-email', objectKey: 'test-email' } } } }] }; handler(event).then(console.log).catch(console.error);"Wrangler Dev (Cloudflare Worker Mode)
To test the Cloudflare Worker path alongside the Docker stack:
cd workers/apinpx wrangler dev # Port 8787, local R2/KV via miniflareWrangler dev is self-contained β it uses miniflare for R2 and KV emulation. It doesnβt conflict with the Docker stack. Use localhost:8787 instead of localhost:8800 for API calls when testing Worker-specific behavior.
Day-to-Day Commands
# Start everythingdocker compose --profile local up -d
# Stop everything (keep data)docker compose --profile local down
# Full reset (delete all data)docker compose --profile local down -v
# Check health./scripts/local-healthcheck.sh
# Rebuild after code changesdocker compose --profile local up -d --build api-node-1 api-node-2
# Tail logsdocker compose --profile local logs -f api-node-1 batch-worker
# Run the web appnpm run dev --filter=web
# Re-run database migrationsdocker compose --profile local restart supabase-migrate
# Re-create MinIO bucketsdocker compose --profile local restart minio-init
# Re-create LocalStack resourcesdocker exec accessible-pdf-localstack /etc/localstack/init/ready.d/init-aws.shTroubleshooting
Services wonβt start
# Check container statusdocker compose --profile local ps
# Check logs for a failing servicedocker compose --profile local logs supabase-dbdocker compose --profile local logs localstackCommon causes:
- Docker not running β start Docker Desktop
- Port conflict β another process is using a required port. Check with
lsof -i :8800 - Insufficient memory β increase Docker Desktop RAM allocation to 8+ GB
Supabase migrations fail
docker compose --profile local logs supabase-migrateIf migrations have errors, they may be idempotent (re-creating existing objects). The init script logs warnings but continues. If you need a clean slate, reset the volume:
docker compose --profile local down -vdocker compose --profile local up -dAPI nodes canβt connect to Supabase
Check that Kong is running and healthy:
curl http://localhost:8000/auth/v1/healthIf not, check the dependency chain: supabase-db β supabase-auth β supabase-kong. The DB must be healthy before auth starts, and auth must be healthy before Kong starts.
KV data lost after restart
Expected behavior. In-memory KV resets when API node containers restart. Sessions will be invalidated β users just need to sign in again. In production, Cloudflare KV persists across requests.
Port 3000 conflict (Grafana vs Next.js)
Grafana is bound to 127.0.0.1:3000. The Next.js dev server will auto-detect the conflict and start on port 3001 instead. Access Grafana at http://localhost:3000 and the web app at http://localhost:3001.
LocalStack resources missing after restart
LocalStack init scripts run from /etc/localstack/init/ready.d/ on container startup. If the container restarted but resources are missing:
docker exec accessible-pdf-localstack /etc/localstack/init/ready.d/init-aws.shFull reset
docker compose --profile local down -v # Remove all containers and volumes./scripts/setup-local.sh # Start freshMemory Requirements
The full stack uses approximately 8-10 GB of RAM:
| Service | Approximate RAM |
|---|---|
| Postgres | 500 MB |
| GoTrue + Kong + PostgREST + Meta | 400 MB |
| MinIO | 200 MB |
| LocalStack | 500 MB |
| API Node x2 (Puppeteer/Chrome) | 1 GB each |
| WeasyPrint | 300 MB |
| Batch Worker | 500 MB |
| Monitoring (Loki/Grafana/Promtail) | 500 MB |
If your machine is constrained, use profiles to run only what you need. The local profile starts everything; the default profile (no flag) starts only the 5 core services (~3 GB).