Skip to content

Configuration

Everything is set in one file, .env, which you copy from the .env.example the repository ships and which the docker-compose.yml beside it reads.

  1. Get the sources and copy the environment file.

    Terminal window
    git clone https://github.com/Metalhearf/badlen.git
    cd badlen
    cp .env.example .env
  2. Fill in the three secrets. The stack will not start without them, and the rest of the file can stay as it ships.

    Terminal window
    $EDITOR .env
  3. Build the image and start both containers.

    Terminal window
    docker compose up -d --build

    The first build takes minutes. The application then applies its pending migrations itself and starts serving; nothing has to be run against the database by hand.

  4. Open http://localhost:8080, or whatever WEB_PORT you chose. The first screen is the login page; First start covers what happens next.

The docker-compose.yml this starts
docker-compose.yml
# Self-hosted stack: PostgreSQL and one application container serving both the interface
# and its API on the published WEB_PORT. Copy .env.example to .env first.
#
# The project name is pinned so the container and volume names are the same on every
# machine. docker-compose.dev.yml carries its own, so both stacks can be up at once.
name: badlen
services:
db:
image: postgres:18-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-badlen}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?empty in .env; fill it with openssl rand -hex 24}
POSTGRES_DB: ${POSTGRES_DB:-badlen}
# The whole data home, not `data` inside it: since 18 the image puts the cluster in a
# per-major subdirectory (18/docker) and refuses a volume written by an earlier major.
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-badlen}']
interval: 10s
timeout: 5s
retries: 5
# The API plus the compiled SPA it serves from /app/public: one container, one origin.
# It applies the pending migrations itself before it starts serving.
app:
build:
context: .
dockerfile: Dockerfile.api
target: runtime
restart: unless-stopped
# WEB_PORT is the port you reach the app on, BIND_ADDRESS which host interfaces it is
# published on. Set BIND_ADDRESS=127.0.0.1 when only a local reverse proxy should reach it.
ports:
- '${BIND_ADDRESS:-0.0.0.0}:${WEB_PORT:-8080}:3001'
depends_on:
db:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgresql://${POSTGRES_USER:-badlen}:${POSTGRES_PASSWORD:?empty in .env; fill it with openssl rand -hex 24}@db:5432/${POSTGRES_DB:-badlen}?schema=public
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:?empty in .env; fill it with openssl rand -hex 32}
SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:?empty in .env; fill it with openssl rand -base64 32}
ACCESS_TOKEN_TTL: ${ACCESS_TOKEN_TTL:-15m}
REFRESH_TOKEN_TTL_DAYS: ${REFRESH_TOKEN_TTL_DAYS:-7}
WEB_ORIGIN: ${PUBLIC_URL:-http://localhost:8080}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
TRUSTED_PROXY_HOPS: ${TRUSTED_PROXY_HOPS:-0}
TRUSTED_PROXY_ADDRESSES: ${TRUSTED_PROXY_ADDRESSES:-}
REGISTRATION_ENABLED: ${REGISTRATION_ENABLED:-true}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_SECURE: ${SMTP_SECURE:-false}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
COINGECKO_DEMO_KEY: ${COINGECKO_DEMO_KEY:-}
OPENFIGI_API_KEY: ${OPENFIGI_API_KEY:-}
healthcheck:
# /api/health answers 503 when the database does not answer, so `healthy` here means
# the application is serving and can reach its database.
test:
- CMD
- node
- -e
- "fetch('http://127.0.0.1:3001/api/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
interval: 30s
timeout: 5s
retries: 5
start_period: 20s
volumes:
pgdata:

Three values ship empty and have to be set before the first start. While any of them is empty, the stack refuses to start and lists every empty variable at once, each with the command that generates it.

They are left empty on purpose. Keys written into an example file would be public and identical on every installation, and anyone who forgot to replace them would run on those.

Terminal window
openssl rand -hex 24 # paste after POSTGRES_PASSWORD=
openssl rand -hex 32 # paste after JWT_ACCESS_SECRET=
openssl rand -base64 32 # paste after SECRET_ENCRYPTION_KEY=
Variable What it is for Constraint
POSTGRES_PASSWORD the database password letters and digits only
JWT_ACCESS_SECRET signs the session token at least 16 characters
SECRET_ENCRYPTION_KEY encrypts your 2FA secret and stored provider API keys must decode to exactly 32 bytes

A passphrase-sealed JSON export is deliberately not tied to that key: the file carries its own derivation parameters, so you can still read it after losing the key.

Two things about the database password. It is interpolated into the connection URL as written, so a /, a ? or a # in it makes that URL unparsable and the application never starts. And it is read only once, when the database directory is created: changing it later changes nothing except that the application loses access to its own database.

Variable Default What it decides
WEB_PORT 8080 the port the whole app is reached on
BIND_ADDRESS 0.0.0.0 which host interfaces that port is published on; 127.0.0.1 keeps it on the machine
PUBLIC_URL http://localhost:8080 the address password-reset links are built from, and the cookie origin
COOKIE_SECURE false marks the session cookie HTTPS-only; set it behind a proxy terminating TLS
TRUSTED_PROXY_HOPS 0 how many reverse proxies of yours stand in front (see Behind a proxy)
TRUSTED_PROXY_ADDRESSES (empty) with two proxies or more, the ones behind the first, by address or CIDR range
REGISTRATION_ENABLED true whether public sign-up is open, as a fallback only
ACCESS_TOKEN_TTL 15m how long a session token lives before it is refreshed
REFRESH_TOKEN_TTL_DAYS 7 how long a session survives without being used
POSTGRES_USER, POSTGRES_DB badlen the database’s user and name, read once when it is created, like its password
COINGECKO_DEMO_KEY, OPENFIGI_API_KEY empty optional free integration keys; both sources work without one

The last two are the operator’s and are set here only: the settings screen has no field for either. REGISTRATION_ENABLED is only a default: the equivalent toggle in Settings → Administration is stored in the database and takes over from this value the first time it is used.

Inside the container PUBLIC_URL is called WEB_ORIGIN: the compose file passes one on as the other, and an image run by hand, as in Testing a backup, expects WEB_ORIGIN. The compose file also sets NODE_ENV, PORT and DATABASE_URL itself, from the values above, so none of the three belongs in .env. The application reads more that the compose file does not pass, all for working on the sources: WEB_DIST, where to find the compiled interface outside the image, and DEMO_SEED_ENABLED, DEMO_SEED_LOCALE and DEMO_RESEED, which seed the demonstration account the test suites and the online demo open. The image’s production mode ignores those three.

The secrets are left out: they are the same three lines in every case.

Reachable from this machine only, and from nothing else on the network:

BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
PUBLIC_URL=http://localhost:8080

Reachable on the home network, at a port of your choosing, from a phone or another computer:

BIND_ADDRESS=0.0.0.0
WEB_PORT=9000
PUBLIC_URL=http://192.168.1.20:9000

Behind a reverse proxy on the same host, serving HTTPS under your own domain:

BIND_ADDRESS=127.0.0.1
WEB_PORT=8080
PUBLIC_URL=https://money.example.org
COOKIE_SECURE=true
TRUSTED_PROXY_HOPS=1

In the second, use the machine’s own address on your network; in the third, your own domain. Behind a proxy covers what the proxy itself needs.

Badlen sends email for a forgotten password, a change of address, and the relay that passes a letter to a reader’s relative, and only if you give it a server. Leave SMTP_HOST empty and the links go to the application log instead, as First start describes; the relay is then not offered at all.

Variable Default What it decides
SMTP_HOST empty the server to send through; empty means this instance sends nothing
SMTP_PORT 587 the port to reach it on
SMTP_SECURE false TLS from the first byte, which is port 465; false upgrades with STARTTLS
SMTP_USER empty the account to authenticate as; empty sends without authenticating
SMTP_PASSWORD empty that account’s password
SMTP_FROM empty the address messages leave from; falls back to SMTP_USER

If you set SMTP_HOST with neither SMTP_FROM nor SMTP_USER, the application refuses to start and names the missing variable. Otherwise the server would reject every message for having no sender, and the instance would look broken when only a line is missing.

The server is not enough on its own: the emailed link is built on PUBLIC_URL, from the table above. Left at its default it is localhost, an address only the machine itself can open, and the reset is then not emailed at all: the link goes to the log, with a line naming the variable. Set it to the address your users type.

The relay asks more of that address: it is opened by someone outside your home, perhaps years later. It refuses a localhost address. An address of your home network, 192.168.1.20 say, passes that check but opens nothing from anywhere else, so a relay worth having needs a PUBLIC_URL reachable from the internet.

Like the two toggles above, these are only defaults. Settings → Administration has the same six fields, stores what you save there encrypted, and takes over from the environment from then on, so you can correct a port without restarting the container. The screen shows where the active values come from, and lets you drop the saved ones to go back to the environment. Send a trial posts one message to your own address and shows the server’s refusal when it fails.

The password can be written but never read back, by the screen or by the API: it is encrypted with SECRET_ENCRYPTION_KEY, like a provider key and your 2FA secret, so restoring a database under a different key means typing it again. A server that is unreachable, refuses the login or presents a certificate nothing trusts never stops the application from starting and never fails a reset request; the link falls back to the log.

Terminal window
docker compose ps
docker compose logs app | tail -30

.env is read only when the containers are created, so after a change, run docker compose up -d again. A plain restart keeps the old values.

Terminal window
docker compose stop # containers stopped, nothing removed
docker compose down # containers and network removed; the volume is untouched
docker compose up -d # back up, without rebuilding the image
docker compose down -v # the volume too: everything the database holds, gone

Next: First start, where the first account is created and takes ownership of the instance.