A miniature e-commerce app that mimics a real-world setup: visitors use a storefront (served by the gateway itself), all API traffic goes through Ferron 3 as an API gateway, and several microservices sit behind it — some public, some gated by authentication and rate limiting. Postgres for state, Redis for distributed rate limiting, RabbitMQ for async order events.
┌─────────────────────────────────────────────┐
visitors ──► :8080 │ Ferron 3 gateway (ferronserver/ferron:3) │
│ static / + /api/* reverse proxy │
└──────┬────────┬────────┬────────┬───────────┘
│ │ │ │
┌──────▼──┐ ┌───▼────┐ ┌─▼──────┐ ┌▼────────────┐
│ auth │ │catalog │ │ orders │ │notifications│
│ :8001 │ │ :8002 │ │ :8003 │ │ :8004 │
└───┬─────┘ └───┬────┘ └───┬────┘ └┬────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────┐ ┌─────┐ ┌────────────────┐
│ postgres │ │redis│ │ rabbitmq │
│ users, │ │rate │ │ orders ──► │
│ products, │ │limit│ │ order.created │
│ orders, │ │buckets│ │
│notifications│ └─────┘ └────────────────┘
└─────────────┘
cp .env.example .env # optional; sets JWT_SECRET
docker compose up -d --build- Storefront: http://localhost:8080
- RabbitMQ management: http://localhost:15672 (guest/guest)
- End-to-end check:
./scripts/demo.sh(10 checks, exits non-zero on failure)
docker compose logs -f gateway # gateway access/error logs
docker compose down # stop everything
docker compose down -v # also wipe postgres/redis/rabbitmq data| Feature | How | Where |
|---|---|---|
| Static frontend + API on one origin | root for /, proxy for /api/* (mixed setup, no CORS needed) |
host block |
| Forwarded auth | auth_to http://auth:8001/verify + copy X-Auth-User X-Auth-Role |
/api/orders, /api/notifications, /api/admin/products |
| Distributed rate limiting | rate_limit tiers + rate_limit_backend { type redis … } shared by all gateway replicas |
per location (see below) |
| Fail-closed login | fail_open false on the auth backend: Redis outage ⇒ logins denied, not unlimited |
/api/auth |
| Response caching | cache { … }, origin sends Cache-Control: public, max-age=30 |
/api/products only |
| Resilience | active_check health probes + circuit_breaker { record_5xx … } |
/api/orders |
| Hardening | security headers, compressed + dynamic_compressed, json_errors |
whole host |
Rate-limit tiers (per client IP, token bucket capacity = rate + burst):
| Route | rate / burst | Notes |
|---|---|---|
/api/auth/* |
5/s, burst 10 | brute-force protection, fail-closed |
/api/products |
100/s, burst 200 | public reads, cached |
/api/orders, /api/admin/products |
20/s, burst 40 | authenticated writes |
/api/notifications |
30/s, burst 60 | authenticated reads |
- Browse (public, cached):
GET /api/products→ gateway (cache lookup, generous bucket) → catalog → Postgres. Repeat hits served from gateway memory for 30s. - Checkout (authenticated, async):
POST /api/orders→ gateway verifies JWT viaauth_to→ orders service prices items, decrements stock transactionally, stores the order, publishesorder.created→202/201returns immediately → notifications worker consumes the event and writes the "Order confirmed" row → frontend polls/api/notifications. - Admin write (authenticated + authorized):
POST /api/admin/products→ same backend as the public catalog, but the gateway enforces auth and the service enforcesrole == admin. (Catalog cache refreshes within ~30s TTL.)
| Method & path | Auth | Service |
|---|---|---|
POST /api/auth/register {email, password} → 201 {token,…} |
no | auth |
POST /api/auth/login → 200 {token,…} |
no | auth |
GET /api/auth/me |
JWT (service-checked) | auth |
GET /api/products, GET /api/products/<id> |
no | catalog |
POST /api/admin/products, PUT/DELETE /api/admin/products/<id> |
forwarded auth, admin | catalog |
GET /api/orders, GET /api/orders/<id> |
forwarded auth | orders |
POST /api/orders {items:[{product_id, qty}]} → 201 |
forwarded auth | orders |
GET /api/notifications |
forwarded auth | notifications |
Make a user admin (product writes require it):
docker compose exec postgres psql -U shop -d shopdb \
-c "UPDATE users SET role='admin' WHERE email='you@example.com';"
# then log in again to get a token with the admin rolelocationstrips its prefix before proxying./api/auth/loginreaches the auth container as/login. Backend routes are written against stripped paths. Exact-match locations strip to/, so an auth endpoint must handle/.auth_toappends the stripped path to its backend URL. Withauth_to http://auth:8001/verify, a check forGET /api/ordersarrives asGET /verify/. The auth service therefore answers verification on/verifyand on any other path (catch-all), except its own public routes. A future Ferron 3 release would not append the stripped path toauth_toURLs.
Services also verify a Bearer JWT themselves (shared JWT_SECRET) instead of
blindly trusting X-Auth-User, so they stay safe if called directly —
gateway auth is the edge check, service auth is defense in depth.
docker-compose.yml # gateway + 4 services + postgres/redis/rabbitmq
gateway/ferron.conf # the whole gateway config (validated with `ferron validate`)
frontend/ # vanilla JS SPA, served by Ferron from /var/www/frontend
services/auth/ # register/login/JWT + /verify for the gateway
services/catalog/ # product CRUD (public reads, admin writes)
services/orders/ # orders + publishes order.created to RabbitMQ
services/notifications/ # GET API + background worker consuming order.created
db/init.sql # schema + 8 demo products
scripts/demo.sh # e2e smoke test through the gateway
- Services run Flask's dev server; use gunicorn/uvicorn + replicas in production.
- All services share one Postgres database for simplicity; real systems give each service its own schema/database and talk service-to-service via APIs.
- Order-event publishing is best-effort; the transactional outbox pattern would make it reliable.
- Per-IP limiting sees the Docker NAT address for all visitors in this setup;
behind a real LB/CDN, set
client_ip_from_headersoremote_addressis real. - No TLS here (plain
:80→:8080); Ferron supports automatic ACME TLS — switch the host block to a real domain and it just works. JWT_SECRETmust be a strong random value outside demos ($ openssl rand -hex 32).
Unlicense, since the whole demo is AI-generated, and AI-generated works would be public domain (and Unlicense is public domain-equivalent). 😅