Ledgr
Network Architecture Documentation
Technical Documentation — v1.0

Ledgr Network Architecture

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.

Network Architecture
Docker Compose
3 (Frontend, API, Database)
Internal / Technical

Table of Contents

  1. 1Overview
  2. 2Network Topology Diagram
  3. 3Container Inventory
  4. 4Network Configuration
  5. 5Port Exposure Matrix
  6. 6Internal DNS & Service Discovery
  7. 7Traffic Flow
  8. 8Network Security Controls
  9. 9Environment & Secrets
  10. 10Useful Commands

Overview

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.

🔒

Defence-in-depth by design

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.

Network Topology Diagram

The diagram below shows how traffic flows from the internet through each layer of the stack.

INTERNET / Browser │ TCP :80 (only public port) ╔═══════════════════════════════════════════════════╗ ║ ledgr_network (bridge) ║ ║ ║ ║ ┌────────────────────────────────────────────┐ ║ ║ │ ledgr-frontend (Nginx 1.27-alpine) │ ║ ║ │ Serves: / → static HTML/CSS/JS │ ║ ║ │ Proxies: /api/* → ledgr-api:4000 │ ║ ║ │ Host port: 80 Internal port: 80 │ ║ ║ └────────────────────┬───────────────────────┘ ║ ║ │ ║ ║ /api/* proxy │ http://ledgr-api:4000 ║ ║ ▼ ║ ║ ┌────────────────────────────────────────────┐ ║ ║ │ ledgr-api (Node.js 20-alpine) │ ║ ║ │ REST API: auth, MFA, admin routes │ ║ ║ │ Host port: none Internal port: 4000 │ ║ ║ └────────────────────┬───────────────────────┘ ║ ║ │ ║ ║ pg driver │ ledgr-db:5432 ║ ║ ▼ ║ ║ ┌────────────────────────────────────────────┐ ║ ║ │ ledgr-db (PostgreSQL 16-alpine) │ ║ ║ │ Stores: users, sessions, audit log │ ║ ║ │ Host port: none Internal port: 5432 │ ║ ║ │ Volume: ledgr_postgres_data (persistent) │ ║ ║ └────────────────────────────────────────────┘ ║ ║ ║ ╚═══════════════════════════════════════════════════╝

Container Inventory

🌐
ledgr-frontend
Nginx 1.27-alpine
  • Serves all static HTML, CSS, JS
  • Reverse-proxies /api/* to API
  • Applies CSP & security headers
  • Rate-limits auth endpoints
  • Publishes port 80 to host
  • Read-only filesystem
⚙️
ledgr-api
Node.js 20-alpine
  • Express REST API on port 4000
  • Auth: login, MFA, JWT, refresh
  • Admin: user CRUD, audit log
  • Parameterized SQL — no injection
  • Runs as non-root user ledgr
  • Internal only — not published
🗄️
ledgr-db
PostgreSQL 16-alpine
  • Persistent volume ledgr_postgres_data
  • Schema auto-applied on init
  • SCRAM-SHA-256 auth
  • Stores users, sessions, audit
  • Internal only — not published
  • Healthcheck: pg_isready

Network Configuration

Docker Compose network definition

# docker-compose.yml — networks block
networks:
  ledgr-net:
    driver: bridge
    name:   ledgr_network
    internal: false   # allows outbound internet (e.g. npm install)
PropertyValueNotes
DriverbridgeStandard Docker bridge — isolated layer-2 network on the host
Network nameledgr_networkExplicit name prevents Docker auto-generated naming
SubnetAuto-assigned by DockerTypically 172.18.x.x/16 range
MembersAll 3 containersledgr-frontend, ledgr-api, ledgr-db
IsolationFullContainers on other Docker networks cannot reach these
Host accessVia published port onlyOnly port 80 on ledgr-frontend
ℹ️

Why a named network?

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.

Port Exposure Matrix

Understanding the difference between ports (published to host) and expose (internal only) is critical to the security posture of this deployment.

ContainerInternal PortProtocolPublished 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
⚠️

Production: add TLS (port 443)

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.

Internal DNS & Service Discovery

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.

HostnameResolves ToPortUsed 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)

How Nginx references the API

# 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;
}

How the API connects to PostgreSQL

# 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,
});

Traffic Flow

The following steps trace a complete request lifecycle from a user's browser through all three containers.

Example: User logs in

1

Browser → Nginx (port 80)

POST /api/auth/login with JSON body {"email":"…","password":"…"}. This is the only publicly reachable entry point.

2

Nginx applies rate limiting

The auth rate limit zone allows max 5 requests/min per IP. Nginx attaches X-Real-IP and X-Forwarded-For headers before proxying.

3

Nginx → ledgr-api:4000 (internal)

Request is forwarded over the Docker bridge network. No external hop — pure in-kernel packet routing between containers.

4

API validates & queries database

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.

5

DB responds over internal network

PostgreSQL sends the result row back to the API over the Docker bridge. The wire protocol never leaves the host machine.

6

API returns JWT + sets httpOnly cookie

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.

7

If MFA enabled — browser redirects to MFA page

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.

Network Security Controls

External DB accessDirect internet connection to PostgreSQL
Database uses expose not ports — only reachable from within ledgr_network. No host binding exists.
Network
Direct API accessBypass Nginx to hit API directly
API container also uses expose only. Cannot be reached from host or internet without going through Nginx.
Network
Brute force loginRepeated password guessing
Nginx rate-limits /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.
App + Net
SQL injectionMalicious input in SQL queries
Every database query uses $1, $2… parameterized placeholders via the pg driver. User values are never string-interpolated into SQL.
App
XSS / header injectionScript injection via HTTP responses
Nginx serves 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.
App + Net
Session hijackingStolen refresh token
Refresh tokens set with HttpOnly; Secure; SameSite=Strict — inaccessible to JavaScript. Only SHA-256 hashes are stored in the database, never raw tokens.
App
Privilege escalationContainer root breakout
API container runs as non-root user ledgr. All containers use no-new-privileges:true and read-only root filesystems where possible.
Container
Secrets in environmentLeaked DB passwords / JWT keys
Secrets loaded from .env file (never committed to git). .gitignore and .dockerignore exclude it. Only the API container receives secret env vars — Nginx gets none.
Config
🔴

Before going to production

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.

Environment & Secrets

VariableUsed ByDescriptionRequired
PGPASSWORDAPI, DBPostgreSQL password — min 24 random charsYES
PGDATABASEAPI, DBDatabase name (default: ledgr)Optional
PGUSERAPI, DBDatabase user (default: ledgr)Optional
JWT_SECRETAPI64-byte hex string for signing JWT tokensYES
ENCRYPTION_KEYAPI32-byte hex key for AES-256-GCM MFA secret encryptionYES
ALLOWED_ORIGINSAPIComma-separated CORS allowed originsYES
ADMIN_EMAILseed.jsInitial admin emailOptional
ADMIN_INITIAL_PASSWORDseed.jsLeave blank for auto-generated secure passwordOptional
NODE_ENVAPISet to production — enables Secure cookies, disables debug logsYES

Generating secrets

# 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

Useful Commands

Starting & stopping

# 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

Viewing logs

# 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

Seeding the admin account

# Run once after first start
docker compose exec ledgr-api node db/seed.js

Inspecting the network

# 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

Database access (admin only)

# 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;"

Healthcheck status

# See health status of all containers
docker compose ps

# Detailed healthcheck info
docker inspect --format='{{json .State.Health}}' ledgr-api | python3 -m json.tool