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.
-
Get the sources and copy the environment file.
Terminal window git clone https://github.com/Metalhearf/badlen.gitcd badlencp .env.example .env -
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 -
Build the image and start both containers.
Terminal window docker compose up -d --buildThe 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.
-
Open
http://localhost:8080, or whateverWEB_PORTyou chose. The first screen is the login page; First start covers what happens next.
The docker-compose.yml this starts
# 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:The three secrets
Section titled “The three secrets”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.
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.
The rest of the file
Section titled “The rest of the file”| 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.
Three settings filled in
Section titled “Three settings filled in”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.1WEB_PORT=8080PUBLIC_URL=http://localhost:8080Reachable on the home network, at a port of your choosing, from a phone or another computer:
BIND_ADDRESS=0.0.0.0WEB_PORT=9000PUBLIC_URL=http://192.168.1.20:9000Behind a reverse proxy on the same host, serving HTTPS under your own domain:
BIND_ADDRESS=127.0.0.1WEB_PORT=8080PUBLIC_URL=https://money.example.orgCOOKIE_SECURE=trueTRUSTED_PROXY_HOPS=1In 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.
The mail server
Section titled “The mail server”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.
Checking it came up
Section titled “Checking it came up”docker compose psdocker 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.
Stopping and restarting
Section titled “Stopping and restarting”docker compose stop # containers stopped, nothing removeddocker compose down # containers and network removed; the volume is untoucheddocker compose up -d # back up, without rebuilding the imagedocker compose down -v # the volume too: everything the database holds, goneNext: First start, where the first account is created and takes ownership of the instance.