Skip to main content

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

ServiceURL
Frontendhttp://localhost:8081
Adminhttp://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 Consolehttp://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.

VariableExample (local only)
POSTGRES_USERMust be postgres (not root; root is for MongoDB)
POSTGRES_PASSWORD, REPLICATION_PASSWORD, REDIS_PASSWORD, MONGO_ROOT_PASSWORDe.g. local_pg_Secret1 (each different)
RABBITMQ_DEFAULT_PASS, RABBITMQ_ERLANG_COOKIEe.g. local_rabbit_Secret1
NEO4J_AUTHneo4j/local_neo4j_Secret1
MINIO_ROOT_USER / MINIO_ROOT_PASSWORDe.g. minioadmin / minioadmin_secret
JwtSettings__SecretRun openssl rand -base64 64 and paste the output
DatabaseAppAesKeyRun 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
CheckURLExpected
API healthhttp://localhost:5000/api/healthJSON with status
API Swaggerhttp://localhost:5000/swaggerAPI docs UI
Admin panelhttp://localhost:8084Admin login
Frontend (main app)http://localhost:8081App 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

  1. Use the same self-host kit on the server (or clone the repo).
  2. Create .env from .env.example with real domain URLs and strong secrets.
  3. Create mongo-keyfile on the server (same commands as above).
  4. Run without the local override so Traefik starts: docker compose pull && docker compose up -d.
  5. 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 / serverLocal (this guide)
Reverse proxyTraefik (HTTPS). Routes /api, /hubs/* to API/workers.api-gateway (nginx) on API_PORT; same path routing, HTTP.
APINot exposed; Traefik talks to api by name.Not exposed; api-gateway talks to api by name.
API envASPNETCORE_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)

  1. Always use both compose files: docker compose -f docker-compose.yml -f docker-compose.local.yml up -d
  2. API port: Must be bound by api-gateway, not the api container. Check docker ps — the api-gateway container should show 0.0.0.0:5000->80/tcp (or your API_PORT); the api container should not expose that port.
  3. 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.
  4. 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.
  5. Frontend SignalR: CSP must allow ws:; use a frontend image that has connect-src ... ws: wss: in nginx.conf.

Troubleshooting

IssueWhat to check
Login doesn't workJwtSettings__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 browserFRONTEND_PUBLIC_URL, ADMIN_PUBLIC_URL, and TRAEFIK_*_HOST in .env should be localhost.
SignalR / WebSocket not connectingEnsure 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 foundCreate mongo-keyfile in the same directory as docker-compose.yml and chmod 600 mongo-keyfile.
Containers exit or unhealthyRun 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 unhealthyEnsure POSTGRES_USER=postgres in .env. Do a clean start: down -v then up -d.
Port 5000 already in useFree 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 failuresVolumes 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/mypassNeo4j__Password=mypass).

Next steps