A comprehensive reference for the Docker network topology, inter-container communication, port exposure strategy, traffic flow, and security controls implemented across the Ledgr accounting platform.
Ledgr runs as a set of three Docker containers orchestrated by Docker Compose. All containers communicate over a private Docker bridge network called ledgr_network. This network is invisible to the host machine and the internet — only the Nginx frontend container exposes a public port.
The architecture follows a reverse-proxy pattern: the browser talks only to Nginx on port 80 (or 443 with TLS). Nginx forwards API calls internally to the Node.js container, which in turn queries the PostgreSQL container — neither of which is reachable from outside.
Even if the host firewall is misconfigured, the database and API are unreachable from the internet because they only expose their ports inside the Docker network — they do not publish to the host.
The diagram below shows how traffic flows from the internet through each layer of the stack.
/api/* to API80 to host4000ledgrledgr_postgres_datapg_isready# docker-compose.yml — networks block networks: ledgr-net: driver: bridge name: ledgr_network internal: false # allows outbound internet (e.g. npm install)
| Property | Value | Notes |
|---|---|---|
| Driver | bridge | Standard Docker bridge — isolated layer-2 network on the host |
| Network name | ledgr_network | Explicit name prevents Docker auto-generated naming |
| Subnet | Auto-assigned by Docker | Typically 172.18.x.x/16 range |
| Members | All 3 containers | ledgr-frontend, ledgr-api, ledgr-db |
| Isolation | Full | Containers on other Docker networks cannot reach these |
| Host access | Via published port only | Only port 80 on ledgr-frontend |
Using an explicit network name (ledgr_network) instead of Docker Compose's default auto-generated name makes the network inspectable and stable across deploys, and prevents accidental cross-project container communication.
Understanding the difference between ports (published to host) and expose (internal only) is critical to the security posture of this deployment.
| Container | Internal Port | Protocol | Published to Host? | Accessible From |
|---|---|---|---|---|
| ledgr-frontend | 80 |
HTTP | YES — port 80 | Internet / browser |
| ledgr-api | 4000 |
HTTP | NO — expose only | ledgr-frontend only (via proxy) |
| ledgr-db | 5432 |
PostgreSQL wire | NO — expose only | ledgr-api only |
In production, add a TLS termination layer in front of Nginx (e.g. Certbot/Let's Encrypt or a cloud load balancer). Change the published port to 443:443 and redirect port 80 to 443 inside nginx.conf.
Docker's embedded DNS resolver automatically maps each container's service name to its internal IP address. No static IPs or hosts files are needed.
| Hostname | Resolves To | Port | Used By |
|---|---|---|---|
ledgr-frontend |
Internal container IP | 80 |
Health checks only |
ledgr-api |
Internal container IP | 4000 |
Nginx proxy_pass, healthchecks |
ledgr-db |
Internal container IP | 5432 |
Node.js pg pool (PGHOST=ledgr-db) |
# nginx/nginx.conf location /api/ { proxy_pass http://ledgr-api:4000; # Docker DNS resolves "ledgr-api" proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
# backend/db/pool.js — pg Pool config const pool = new Pool({ host: 'ledgr-db', // resolved by Docker DNS port: 5432, database: 'ledgr', user: 'ledgr', password: process.env.PGPASSWORD, });
The following steps trace a complete request lifecycle from a user's browser through all three containers.
POST /api/auth/login with JSON body {"email":"…","password":"…"}. This is the only publicly reachable entry point.
The auth rate limit zone allows max 5 requests/min per IP. Nginx attaches X-Real-IP and X-Forwarded-For headers before proxying.
Request is forwarded over the Docker bridge network. No external hop — pure in-kernel packet routing between containers.
Express validates input with express-validator, then issues a parameterized query to ledgr-db:5432 to look up the user. Bcrypt compares the password hash.
PostgreSQL sends the result row back to the API over the Docker bridge. The wire protocol never leaves the host machine.
A signed 15-minute JWT access token is returned in the JSON body. A 7-day refresh token is set as a Secure; HttpOnly; SameSite=Strict cookie.
A partial (pre-MFA) token is returned. Browser loads /auth/mfa.html, user enters 6-digit TOTP code, API verifies against the decrypted TOTP secret and issues a full token.
expose not ports — only reachable from within ledgr_network. No host binding exists.expose only. Cannot be reached from host or internet without going through Nginx./api/auth/* to 5 req/min per IP. Express adds a second layer at 10 req/15min. After 5 failed attempts the account locks for 30 minutes.$1, $2… parameterized placeholders via the pg driver. User values are never string-interpolated into SQL.Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, X-XSS-Protection headers on every response. Helmet.js adds the same on API responses.HttpOnly; Secure; SameSite=Strict — inaccessible to JavaScript. Only SHA-256 hashes are stored in the database, never raw tokens.ledgr. All containers use no-new-privileges:true and read-only root filesystems where possible..env file (never committed to git). .gitignore and .dockerignore exclude it. Only the API container receives secret env vars — Nginx gets none.Add TLS (HTTPS) by terminating SSL at Nginx or a cloud load balancer. Without TLS, JWT tokens and credentials travel over plaintext HTTP. The Secure cookie flag and HSTS header only take effect over HTTPS.
| Variable | Used By | Description | Required |
|---|---|---|---|
PGPASSWORD | API, DB | PostgreSQL password — min 24 random chars | YES |
PGDATABASE | API, DB | Database name (default: ledgr) | Optional |
PGUSER | API, DB | Database user (default: ledgr) | Optional |
JWT_SECRET | API | 64-byte hex string for signing JWT tokens | YES |
ENCRYPTION_KEY | API | 32-byte hex key for AES-256-GCM MFA secret encryption | YES |
ALLOWED_ORIGINS | API | Comma-separated CORS allowed origins | YES |
ADMIN_EMAIL | seed.js | Initial admin email | Optional |
ADMIN_INITIAL_PASSWORD | seed.js | Leave blank for auto-generated secure password | Optional |
NODE_ENV | API | Set to production — enables Secure cookies, disables debug logs | YES |
# JWT secret (64 bytes) node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" # Encryption key (32 bytes) node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Or with openssl openssl rand -hex 64 # JWT_SECRET openssl rand -hex 32 # ENCRYPTION_KEY
# Start all containers docker compose up -d --build # Stop all containers docker compose down # Stop and remove volumes (DELETES ALL DATA) docker compose down -v
# All containers docker compose logs -f # Specific container docker compose logs -f ledgr-api docker compose logs -f ledgr-db docker compose logs -f ledgr-frontend
# Run once after first start
docker compose exec ledgr-api node db/seed.js
# Show all containers and their IPs on the network docker network inspect ledgr_network # Verify containers can reach each other docker compose exec ledgr-api ping ledgr-db docker compose exec ledgr-frontend ping ledgr-api # Check open ports on the host (only port 80 should appear) ss -tlnp | grep docker
# Open psql shell inside the DB container docker compose exec ledgr-db psql -U ledgr -d ledgr # Run a query docker compose exec ledgr-db psql -U ledgr -d ledgr \ -c "SELECT id, email, role, mfa_enabled FROM users;"
# See health status of all containers docker compose ps # Detailed healthcheck info docker inspect --format='{{json .State.Health}}' ledgr-api | python3 -m json.tool