Local Deployment
Docker-based local development infrastructure and setup.
Local Deployment
ByteBite local development runs entirely in Docker with Traefik for hostname-based routing. No Dokploy or TLS is involved.
Prerequisites
- macOS (primary target) or Linux
- Docker Desktop or compatible runtime (Compose v2+)
- Git, pnpm, Node.js 24 LTS (for host-side lint/typecheck/build)
/etc/hostsentries (see below)
Architecture
Browser -> Traefik :80 (compose.yml)
-> file provider (infrastructure/traefik/dev/dynamic.yml)
-> landing | storefront | dashboard | api | docs
-> PostgreSQL (backend network)
-> MinIO (backend network, API only)Traefik uses the file provider only — Docker provider is disabled for OrbStack compatibility. Service traefik.* labels in compose.yml are documentation only.
See Network for Docker network details and Architecture for request flows.
Compose files
| File | Purpose |
|---|---|
compose.yml | Base services, networks, Traefik, PostgreSQL, MinIO |
compose.dev.yml | Dev overrides: bind mounts, hot reload commands |
Start with:
pnpm dev # docker compose -f compose.yml -f compose.dev.yml upLocal hostnames
Domain suffix: .bytebite.test
| Host | Service | Port (internal) |
|---|---|---|
testlinks.bytebite.test | Static local link page (nginx) | 80 |
storefront.bytebite.test | Storefront (neutral dev host) | 3000 |
dashboard.bytebite.test | Dashboard (Vite dev) | 5173 |
api.bytebite.test | API (direct) | 3001 |
docs.bytebite.test | Docs | 3002 |
landing.bytebite.test | Landing (Coming Soon) | 3003 |
luigi.bytebite.test | Storefront (seeded tenant) | 3000 |
mario.bytebite.test | Storefront (seeded tenant) | 3000 |
unknown.bytebite.test | Storefront (unknown tenant test) | 3000 |
No wildcard DNS — each host must be listed in /etc/hosts.
/etc/hosts — IPv4 and IPv6 required
macOS clients may attempt AAAA (IPv6) lookups for .test hostnames. If only 127.0.0.1 entries exist, requests can hang for ~5 seconds before falling back to IPv4.
Add both 127.0.0.1 and ::1 for every host:
127.0.0.1 testlinks.bytebite.test
127.0.0.1 storefront.bytebite.test
127.0.0.1 dashboard.bytebite.test
127.0.0.1 api.bytebite.test
127.0.0.1 docs.bytebite.test
127.0.0.1 landing.bytebite.test
127.0.0.1 luigi.bytebite.test
127.0.0.1 mario.bytebite.test
127.0.0.1 unknown.bytebite.test
::1 testlinks.bytebite.test
::1 storefront.bytebite.test
::1 dashboard.bytebite.test
::1 api.bytebite.test
::1 docs.bytebite.test
::1 landing.bytebite.test
::1 luigi.bytebite.test
::1 mario.bytebite.test
::1 unknown.bytebite.testSee root README.md for a convenient macOS one-liner.
Same-origin /api routing
Browsers call /api/* on the same host as the UI. Traefik strips the /api prefix before forwarding to Fastify:
http://luigi.bytebite.test/api/health
-> Traefik (strip-api-prefix middleware)
-> api:3001 /healthConfigured in infrastructure/traefik/dev/dynamic.yml. No CORS layer is needed for the normal browser path.
API_INTERNAL_URL
Storefront SSR uses API_INTERNAL_URL=http://api:3001 (set in compose.yml). Server-side fetches send X-ByteBite-Tenant-Host with the original browser hostname. See Architecture.
PostgreSQL and MinIO
| Service | Access |
|---|---|
| PostgreSQL | 127.0.0.1:5432 (host-published for tooling) |
| MinIO console | http://localhost:9001 |
| Traefik dashboard | http://localhost:8080 |
| Drizzle Studio | http://localhost:4983 (pnpm db:studio) |
Inside the API container, Compose overrides DATABASE_URL to postgres://...@postgres:5432/... and S3_ENDPOINT to http://minio:9000.
Migrations and seeds
The API does not auto-migrate. With the stack running:
pnpm db:migrate
pnpm db:seedLocal seeds create luigi.bytebite.test and mario.bytebite.test, plus demo staff users restaurant@bytebite.test (RESTAURANT) and admin@bytebite.test (ADMIN). Passwords are seed/test secrets only (restaurant-seed / admin-seed). Seeds do not create carts or orders. Staff login is http://dashboard.bytebite.test/login. Do not run pnpm db:seed:staging locally — it writes staging hostnames.
BETTER_AUTH_SECRET is wired for staff login (Better Auth). The local placeholder (change-me-to-a-long-random-string) is not a production secret. Production/staging must set a long random secret; without it the API does not register /auth routes (no silent Better Auth default) and /dashboard/* returns 401. session.restaurantId is not this slice — dashboard mutations stay on the path. Dashboard API calls need the session cookie; Traefik BasicAuth is not a substitute.
Smoke tests
curl -s "http://api.bytebite.test/health"
curl -s "http://luigi.bytebite.test/api/health"
curl -s "http://luigi.bytebite.test/api/storefront/restaurant"
curl -s "http://luigi.bytebite.test/api/storefront/hours"
curl -s "http://luigi.bytebite.test/api/storefront/menu"
curl -s "http://luigi.bytebite.test/api/storefront/cart"
curl -s "http://mario.bytebite.test/api/storefront/menu"
curl -s "http://mario.bytebite.test/api/storefront/cart"
curl -s "http://unknown.bytebite.test/api/storefront/restaurant" -o /dev/null -w "%{http_code}"
curl -s "http://unknown.bytebite.test/api/storefront/menu" -o /dev/null -w "%{http_code}"
curl -s "http://unknown.bytebite.test/api/storefront/cart" -o /dev/null -w "%{http_code}"
curl -s "http://unknown.bytebite.test/api/storefront/checkout" -o /dev/null -w "%{http_code}"
curl -s "http://unknown.bytebite.test/api/storefront/hours" -o /dev/null -w "%{http_code}"Unknown-host restaurant, menu, cart, checkout, and hours routes return 404. Luigi's menu includes Margherita and excludes Carbonara; Mario is the inverse. Empty carts are 200 with items: []. A Luigi bb_cart cookie sent to Mario yields a new empty Mario cart and cannot create a Luigi order. Body restaurantId does not change the tenant. A Luigi orderId on Mario returns 404. Dashboard hours and logo UI: http://dashboard.bytebite.test/. Dashboard menu UI (including category/item image upload): http://dashboard.bytebite.test/menu. Dashboard accept/reject and kitchen UI: http://dashboard.bytebite.test/orders. Pickup checkout is 409 when the hostname restaurant is closed; cart/menu GETs stay 200.
Checkout isolation after seed (no orders are seeded):
curl -s -D - -H "content-type: application/json" \
-d '{"menuItemId":"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa5","quantity":2}' \
"http://luigi.bytebite.test/api/storefront/cart/items"
curl -s -H "content-type: application/json" \
-H "cookie: bb_cart=<luigi-cart-uuid>" \
-d '{"name":"Anna","phone":"+491111111111","restaurantId":"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"}' \
"http://luigi.bytebite.test/api/storefront/checkout"
curl -s -o /dev/null -w "%{http_code}" \
"http://mario.bytebite.test/api/storefront/orders/<luigi-order-uuid>"Luigi snapshots Margherita at 850 / 1700. Mario returns 404 for that orderId.
Accept / reject / timeout (after checkout; seeds still create no orders). Dashboard writes need a staff session (not Traefik BasicAuth):
LUIGI_ID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"
MARIO_ID="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"
curl -s -c /tmp/bb-staff.txt -H "content-type: application/json" \
-H "origin: http://dashboard.bytebite.test" \
-d '{"email":"restaurant@bytebite.test","password":"restaurant-seed"}' \
"http://dashboard.bytebite.test/api/auth/sign-in/email"
curl -s -b /tmp/bb-staff.txt -o /dev/null -w "%{http_code}" \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/menu"
# Expected without cookie: 401. Expected with cookie: 200.
curl -s -b /tmp/bb-staff.txt -H "content-type: application/json" \
-d '{"etaMinutes":30}' \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/accept"
curl -s -o /dev/null -w "%{http_code}" -b /tmp/bb-staff.txt \
-H "content-type: application/json" \
-d '{"etaMinutes":30}' \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${MARIO_ID}/orders/<luigi-order-uuid>/accept"Luigi accept returns accepted with ETA 30. The same orderId under Mario's path returns 404 and does not write. Dashboard menu without a session is 401. Staff login/logout: http://dashboard.bytebite.test/login.
Kitchen after accept (seeds still create no orders):
curl -s -b /tmp/bb-staff.txt -H "content-type: application/json" \
-d '{}' \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/preparing"
curl -s -b /tmp/bb-staff.txt -H "content-type: application/json" \
-d '{}' \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/ready"
curl -s -b /tmp/bb-staff.txt -H "content-type: application/json" \
-d '{}' \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/orders/<luigi-order-uuid>/complete"
curl -s -o /dev/null -w "%{http_code}" -b /tmp/bb-staff.txt \
-H "content-type: application/json" \
-d '{}' \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${MARIO_ID}/orders/<luigi-order-uuid>/preparing"Luigi kitchen writes return preparing, then ready, then completed (ETA 30 and snapshot prices unchanged). The same orderId under Mario's path returns 404 and does not write.
Logo upload (multipart file; seeds still create no uploads):
LUIGI_ID="aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1"
MARIO_ID="bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1"
curl -s -b /tmp/bb-staff.txt -F "file=@./logo.png;type=image/png" \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/logo"
curl -s "http://luigi.bytebite.test/api/storefront/restaurant"
curl -s "http://mario.bytebite.test/api/storefront/restaurant"
curl -s -o /dev/null -w "%{http_code}" -b /tmp/bb-staff.txt -F "file=@./logo.png;type=image/png" \
"http://dashboard.bytebite.test/api/dashboard/restaurants/${LUIGI_ID}/items/${MARIO_ID}/image"Luigi logoUrl is a /api/media/restaurants/${LUIGI_ID}/... path. Mario's restaurant payload still has logoUrl: null. A Mario id in Luigi's item-image path returns 404.
More checks: Validation.
Testing deployment compose locally
Use a separate project name to avoid replacing dev containers:
docker network create dokploy-network
docker compose -p bytebite-staging -f compose.deploy.yml up --buildDev images are tagged bytebite-<service>:dev and deploy images bytebite-<service>:prod, so a missing -p no longer overwrites the images pnpm dev starts. Still use -p bytebite-staging so deploy does not replace running dev containers.
dokploy-network is declared external: true in compose.deploy.yml (required by the migrate one-shot on staging). Create it first or compose fails. This local run still does not include Traefik, PostgreSQL, or local routing; migrate / api need a reachable DATABASE_URL.
Troubleshooting
| Issue | Fix |
|---|---|
| ~5s request delay on macOS | Add ::1 entries to /etc/hosts |
api.bytebite.test connection refused | Ensure pnpm dev is running |
Cannot find module '/app/pnpm' | Dev stack started production images. pnpm dev:down then pnpm dev (builds :dev tags). |
Dashboard pnpm: not found (exit 127) | Same cause as above — dashboard prod is nginx, which has no pnpm |
| New docs page not visible | Restart docs service after adding MDX files |
| Host-side build corrupts Next dev cache | Anonymous volumes protect .next — avoid host pnpm build for storefront/docs during dev |
See Troubleshooting for infrastructure issues.
Related
- Getting started — first-time monorepo setup
- Environments — local vs staging comparison
- ADR-012 Local development
- ADR-013 Local domains