Test the stack on your Mac (no server needed)
Run the same self-host stack locally (e.g. on your Mac) before deploying to a server. No domain, no VPS cost — everything runs in Docker on your machine. This guide matches the dockerPublish kit (the standard self-host folder). When it works locally, the same setup runs on a Linux server; see Self-Host with Pre-Built Images for production.
Important: The compose and Postgres setup are aligned with production. Postgres init runs only when the primary volume is empty. To avoid "database MigrationDb does not exist" or "no pg_hba.conf entry for replication", do a clean run: docker compose -f docker-compose.yml -f docker-compose.local.yml down -v then up -d.
What you get
| Service | URL |
|---|---|
| Frontend | http://localhost:8081 |
| Admin | http://localhost:8084 |
| API + WebSocket (SignalR) | http://localhost:5000 (API, Swagger, notification/chat hubs on same host; default port, or set API_PORT in .env) |
| MinIO (S3) | http://localhost:9000 — set Minio__PublicUrl=http://localhost:9000 in .env so the frontend can load media. |
| MinIO Console | http://localhost:9001 |
Traefik is not started when you use the local override. An api-gateway (nginx) routes /api, /swagger, /health to the API and /hubs/* to the WebSocket and Chat workers, so real-time features work without Traefik. You don't need port 80/443 or a domain.
Prerequisites
- Docker Desktop (or Docker Engine + Compose) on your Mac
- Resources: 16GB+ RAM and 8+ CPU recommended
- Disk: Several GB for images and volumes
Step 1: Create .env
From the folder that contains docker-compose.yml (the self-host kit):
cp .env.example .env
Step 2: Set local URLs and secrets in .env
Edit .env and set at least the following.
Public URLs (localhost for local testing)
API_PUBLIC_URL=http://localhost:5000
FRONTEND_PUBLIC_URL=http://localhost:8081
ADMIN_PUBLIC_URL=http://localhost:8084
DOCS_PUBLIC_URL=https://docs.bellamybook.com
Minio__PublicUrl=http://localhost:9000
If port 5000 is in use, set API_PORT=5001 and API_PUBLIC_URL=http://localhost:5001 (and use that port for API/Swagger/SignalR).
Traefik hostnames (use localhost so CORS and rate-limit whitelist work)
TRAEFIK_API_HOST=localhost
TRAEFIK_FRONTEND_HOST=localhost
TRAEFIK_ADMIN_HOST=localhost
TRAEFIK_DOCS_HOST=docs.bellamybook.com
TRAEFIK_DASHBOARD_HOST=localhost
TRAEFIK_DASHBOARD_IP=127.0.0.1
Replace every CHANGE_ME_*
Use strong random values for production; for local testing you can use simple but unique passwords. JWT is required for login.
| Variable | Example (local only) |
|---|---|
POSTGRES_USER | Must be postgres (not root; root is for MongoDB) |
POSTGRES_PASSWORD, REPLICATION_PASSWORD, REDIS_PASSWORD, MONGO_ROOT_PASSWORD | e.g. local_pg_Secret1 (each different) |
RABBITMQ_DEFAULT_PASS, RABBITMQ_ERLANG_COOKIE | e.g. local_rabbit_Secret1 |
NEO4J_AUTH | neo4j/local_neo4j_Secret1 |
MINIO_ROOT_USER / MINIO_ROOT_PASSWORD | e.g. minioadmin / minioadmin_secret |
JwtSettings__Secret | Run openssl rand -base64 64 and paste the output |
DatabaseAppAesKey | Run openssl rand -base64 32 and paste |
Turnstile (CAPTCHA) is optional for local testing; you can get free Cloudflare Turnstile keys for localhost if needed.
Step 3: Create MongoDB keyfile (required)
In the same directory as docker-compose.yml:
openssl rand -base64 756 > mongo-keyfile
chmod 600 mongo-keyfile
Do not commit mongo-keyfile to git.
Step 4: Start the stack without Traefik (local override)
Use the local override so Traefik is not started (saves ports 80/443 and resources):
docker compose -f docker-compose.yml -f docker-compose.local.yml pull
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
To follow logs: docker compose -f docker-compose.yml -f docker-compose.local.yml up
Step 5: Wait for services and migration
- The db-migration service runs automatically after Postgres and MongoDB are healthy, then exits.
- First startup can take several minutes (Kafka, Elasticsearch, MongoDB replica set, etc.).
Check containers:
docker compose -f docker-compose.yml -f docker-compose.local.yml ps
Step 6: Verify and open the app
Use your API port if you set API_PORT (e.g. 5001); otherwise 5000.
curl -s http://localhost:5000/api/health
| Check | URL | Expected |
|---|---|---|
| API health | http://localhost:5000/api/health | JSON with status |
| API Swagger | http://localhost:5000/swagger | API docs UI |
| Admin panel | http://localhost:8084 | Admin login |
| Frontend (main app) | http://localhost:8081 | App home / sign-in |
Default admin account (if seeded): Email [email protected], Password Admin123@. Change this password immediately after first login (Admin Panel → Profile or account settings).
Register a user and log in. If login fails, double-check JwtSettings__Secret and that API_PUBLIC_URL / FRONTEND_PUBLIC_URL in .env match the URLs you use. If the frontend container is restarting, run docker compose -f docker-compose.yml -f docker-compose.local.yml logs frontend to see the error.
Step 7: Stop and clean up (optional)
Stop everything:
docker compose -f docker-compose.yml -f docker-compose.local.yml down
To remove volumes (full reset, deletes DBs):
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
When you deploy to a real server
- Use the same self-host kit on the server (or clone the repo).
- Create
.envfrom.env.examplewith real domain URLs and strong secrets. - Create
mongo-keyfileon the server (same commands as above). - Run without the local override so Traefik starts:
docker compose pull && docker compose up -d. - Point DNS to the server and configure HTTPS; see Make sure Traefik works when you deploy.
Why local override only (no app code change)
Code is the same for local and production. The difference is environment:
| Production / server | Local (this guide) | |
|---|---|---|
| Reverse proxy | Traefik (HTTPS). Routes /api, /hubs/* to API/workers. | api-gateway (nginx) on API_PORT; same path routing, HTTP. |
| API | Not exposed; Traefik talks to api by name. | Not exposed; api-gateway talks to api by name. |
| API env | ASPNETCORE_ENVIRONMENT=Production. | Override sets Local so cookie Secure=false and refresh token in body for admin/frontend login over HTTP. |
| CSP (frontend) | HTTPS → wss: for SignalR. | HTTP → ws: in connect-src for ws://localhost:API_PORT. |
No backend/frontend logic change — only compose override and frontend CSP so the same code runs on both.
Clean start (fix password / replication / DB errors)
If you see "password authentication failed for user postgres", "no pg_hba.conf entry for replication", or MongoDB/Neo4j auth failures, the existing data volumes were created with different credentials than in your current .env. Do a full reset (this deletes all DB data):
docker compose -f docker-compose.yml -f docker-compose.local.yml down -v
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
Wait a few minutes, then check: docker compose -f docker-compose.yml -f docker-compose.local.yml logs postgres-replica (should show "pg_basebackup succeeded"). Primary init scripts run only on first start (empty volume).
Debug checklist (stable local run)
- Always use both compose files:
docker compose -f docker-compose.yml -f docker-compose.local.yml up -d - API port: Must be bound by api-gateway, not the api container. Check
docker ps— the api-gateway container should show0.0.0.0:5000->80/tcp(or yourAPI_PORT); the api container should not expose that port. - SignalR negotiate:
curl -s -o /dev/null -w "%{http_code}" "http://localhost:5000/hubs/notification/negotiate?negotiateVersion=1"should return 401 (not 404). If 404, the request is hitting the API instead of the api-gateway. - Admin login: API must run with Local env (override sets
ASPNETCORE_ENVIRONMENT: Local). Recreate:docker compose -f docker-compose.yml -f docker-compose.local.yml up -d api --force-recreate, then hard-refresh the admin page. - Frontend SignalR: CSP must allow
ws:; use a frontend image that hasconnect-src ... ws: wss:in nginx.conf.
Troubleshooting
| Issue | What to check |
|---|---|
| Login doesn't work | JwtSettings__Secret set (min 32 chars); API_PUBLIC_URL and FRONTEND_PUBLIC_URL match how you open the app (e.g. http://localhost:5000, http://localhost:8081). |
| CORS errors in browser | FRONTEND_PUBLIC_URL, ADMIN_PUBLIC_URL, and TRAEFIK_*_HOST in .env should be localhost. |
| SignalR / WebSocket not connecting | Ensure api, websocket-worker, and chat-worker have CORS origins (from compose). Recreate: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d api websocket-worker chat-worker --force-recreate, then hard-refresh the app. |
| mongo-keyfile not found | Create mongo-keyfile in the same directory as docker-compose.yml and chmod 600 mongo-keyfile. |
| Containers exit or unhealthy | Run docker compose -f docker-compose.yml -f docker-compose.local.yml logs -f and check the failing service (Postgres, MongoDB, or Kafka often need more time on first start). |
| postgres-primary is unhealthy | Ensure POSTGRES_USER=postgres in .env. Do a clean start: down -v then up -d. |
| Port 5000 already in use | Free the port or set API_PORT=5001 and API_PUBLIC_URL=http://localhost:5001 in .env. |
| Postgres: "Role "root" does not exist" | Set POSTGRES_USER=postgres in .env (not root). |
| Postgres: "password authentication failed for user postgres" | Primary volume was created with a different POSTGRES_PASSWORD. Do a clean start: down -v then up -d. |
| Postgres replica / pg_basebackup / "no pg_hba.conf entry for replication" | Do a clean start (down -v, then up -d). Primary init runs only when the volume is new. |
| "relation MediaFiles does not exist" / "database MigrationDb does not exist" | Schema or MigrationDb missing. Do a clean start (down -v, then up -d). |
| MongoDB or Neo4j auth failures | Volumes created with different credentials. Do a clean start (down -v, then up -d). |
| Neo4j connection errors (app) | Set Neo4j__Password in .env to match the password in NEO4J_AUTH (e.g. NEO4J_AUTH=neo4j/mypass → Neo4j__Password=mypass). |
Next steps
- Self-Host with Pre-Built Images — Full installation and production steps
- Make sure Traefik works when you deploy — Checklist for server deploy with Traefik