Basalt docs
GitHub

Configuration

Basalt is configured entirely by environment variables. There is no config file, and no setting exists that changes what code runs — self-host and cloud are the same image, differing only by configuration (ADR-0008).

The server validates its environment at startup and refuses to boot on anything invalid, naming the variable and the problem. A typo is a crash on line one, not a subtle misbehaviour at 3 a.m.

How a setting reaches the server

Write it in infra/docker/.env, one NAME=value per line, and restart the app container. Both shipped compose files pass that whole file into the container (env_file), so every setting in the tables below works by being written down once.

It is worth knowing what that fixes, because older instructions on the internet say otherwise. infra/docker/.env is read by Compose, to substitute ${...} placeholders while it parses the compose file — that is not the same thing as the environment the server process sees. For a long while only the ten variables explicitly listed in the app service's environment: block crossed that gap, and anything else was accepted by Compose, ignored by the server, and needed a compose override file to have any effect. It no longer does.

Three things still deserve a warning:

  • A few settings are the image's, not yours. PORT, NODE_ENV, SERVE_STATIC_DIR and STORAGE_DIR are pinned by the compose file because the port mapping and the attachment volume depend on them. Setting them in .env has no effect, on purpose — see the notes on those rows.
  • "Set to nothing" is not "unset". TRUST_PROXY= with nothing after it now genuinely puts an empty string in the server's environment, and most settings reject one — the server refuses to boot and names the variable. Delete the line instead of blanking it. (The mail settings are the deliberate exception; for them empty means off.)
  • The file is not the only way. Variables exported in the shell, or injected by a PaaS, work the same. The file is optional; the required-secret guards are not.

Core

Variable Required Default Reaches the container What it does
DATABASE_URL yes yes (assembled from POSTGRES_PASSWORD) Postgres connection string. The only datastore Basalt requires (ADR-0005).
AUTH_SECRET yes yes Signs sessions and auth tokens. At least 32 characters; generate with openssl rand -hex 32. Changing it invalidates every session. Never share one between instances.
BASE_URL no http://localhost:3000 yes The public origin of this instance. Auth callbacks, links, and the cross-origin write check all derive from it. Must be the URL people actually type, including scheme and any non-default port.
EXTRA_ORIGINS no unset yes Further public origins this same instance answers on, comma-separated (https://app.lithicapp.io). See Serving more than one domain.
PORT no 3000 pinned by the compose file TCP port the server listens on. The published port maps host 8080 to container 3000, so moving this would break the mapping with no error anywhere; change the mapping instead.

POSTGRES_PASSWORD is not a server setting — it is consumed by the compose file, which puts it into DATABASE_URL and into the Postgres container.

Serving more than one domain

One instance can answer on several public origins. That is how Basalt and Lithic run as separate products on separate domains from a single server process (ADR-0023):

BASE_URL=https://app.basaltapp.io
EXTRA_ORIGINS=https://app.lithicapp.io

BASE_URL stays the canonical origin. EXTRA_ORIGINS names the others, and the list is used in three places that must agree: the cross-origin write check, the origins the auth layer trusts for credential requests, and the origin a user-facing auth link is built on.

Why not just point both domains at the server and be done? Two reasons it would not work. Sign-in would be refused on the second domain — the auth layer only trusts origins it was told about, and answers everything else with 403 INVALID_ORIGIN. And a password reset or invitation triggered from the second product would arrive with a link into the first one.

What follows the request, and what does not. Password-reset and invitation links are built on whichever configured origin the request came from, so somebody using Lithic stays in Lithic. Billing return URLs and public share links stay on BASE_URL: both are Basalt surfaces, and a share link handed to an outsider should be stable rather than depending on which tab created it.

A host that is not in the list can do nothing. It cannot write, and it cannot appear in a link — an unrecognised Host or Origin header falls back to BASE_URL rather than being echoed. That is deliberate: a link built from an attacker-supplied header would let whoever triggers a password reset choose which server the token is delivered to.

Each entry must be a full origin with scheme, and the scheme has to match how people actually reach the site — http://app.lithicapp.io does not authorise https://app.lithicapp.io. A malformed entry is a startup error, not a silently ignored one.

This variable does not reach the container in the shipped compose.selfhost.yml's environment: block by name — it arrives through env_file, so putting it in infra/docker/.env works. Setting it there and seeing no effect means you are on an older compose file.

Behind a reverse proxy

Variable Required Default Reaches the container What it does
TRUST_PROXY once a proxy is in front off yes Whether to take the client address from X-Forwarded-For, and from whom.

TRUST_PROXY decides what the sign-in rate limiter counts as "a client". Left off, that is the TCP peer — which behind a reverse proxy is the proxy: every visitor shares one budget, and ten failed sign-ins by anybody lock out everybody.

Turning it on carelessly is worse. TRUST_PROXY=true believes whatever X-Forwarded-For says, so any client can mint itself a fresh address per request. That removes the limiter rather than fixing it.

Name the proxy instead — correct and unspoofable:

TRUST_PROXY=172.18.0.0/16   # the docker network your proxy sits on

It accepts the same values Fastify's trustProxy does: true, a hop count (1), or a comma-separated list of addresses/CIDRs. A hop count is safe only when exactly one proxy is in front and nothing can reach the app's port directly. Prefer the CIDR.

compose.services.yml — the two-domain deployment — sets it to 172.16.0.0/12 by default, because in that topology there is always a proxy in front and sometimes two (the edge proxy, and Lithic's nginx for the requests it forwards). That range covers the usual Docker networks. If your proxy is somewhere else, name it in .env; the value there wins.

Rate limiting

Variable Default Reaches the container What it does
AUTH_SIGNIN_PER_MINUTE 10 yes Sign-in attempts allowed per minute, per client address.
AUTH_SIGNUP_PER_MINUTE 5 yes Account creations allowed per minute, per client address.
AUTH_REQUESTS_PER_MINUTE 100 yes Every other auth request per minute, per client address — chiefly the session read the app performs on each page load.

The third one is easy to overlook and worth understanding before you tune the other two. Raising the credential budgets alone does not raise the ceiling an automated client actually hits first: ordinary session traffic is counted here. When that budget runs out the failure does not announce itself as "rate limited" — the app simply stops rendering the screen it was about to show, which reads like an unrelated bug in whatever came next.

Rate limiting on the credential endpoints is always on and has no off switch. It deliberately does not key off NODE_ENV, which is what the auth library's own limiter uses: whether sign-in can be brute-forced should not depend on an environment variable an operator may never set.

Both are minimums of 1 — you can loosen or tighten, not disable. They exist as settings mainly so a test suite that legitimately creates dozens of accounts against one instance can raise them without turning the control off.

The early-access form

One more budget, and it guards the only part of the API a caller reaches with no session at all: the marketing site's early-access signup.

Variable Default Reaches the container What it does
WAITLIST_SIGNUPS_PER_MINUTE 10 yes Signup submissions allowed per minute, per client address.

The budgets above guard the credential endpoints and say nothing about this one, which is why it exists separately. A second limit — three attempts per hour for one email address, whoever asks — is deliberately not configurable: it is what stops a stranger's mailbox from being used to send them confirmation mails, and that is not a number an operator should be able to raise.

Minimum of 1, like the others: loosen or tighten, never disable.

Set TRUST_PROXY if you run behind a proxy and care about the consent record. A signup stores the client address as evidence that consent was given (GDPR Art. 7(1)). Without TRUST_PROXY naming your proxy, every signup records the proxy's address instead of the visitor's, and the evidence is worthless — a mistake nothing in the code can detect.

Storage

Attachments (uploaded images) go to one of two backends.

Variable Default Reaches the container What it does
STORAGE_DRIVER local yes local (disk) or s3.
STORAGE_DIR /data/attachments pinned by the compose file Base directory for the local driver. Both shipped compose files mount a named volume at exactly this path and pin the variable, so an .env entry cannot move the store out from under the volume. Unused when the driver is s3.
S3_BUCKET yes Required when STORAGE_DRIVER=s3.
S3_REGION yes Required when STORAGE_DRIVER=s3. Any value your provider accepts; some non-AWS ones ignore it and auto is conventional.
S3_ENDPOINT yes Custom endpoint (MinIO, R2, Backblaze, Hetzner, …). Setting it switches the client to path-style addressing, which is what every S3-compatible provider that is not AWS expects. Leave it unset for AWS S3 itself.

S3 credentials are not Basalt settings: the client uses the standard AWS provider chain (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, an instance role, a profile). Only bucket, region and endpoint are configured here. Since the whole .env reaches the container, the two access-key variables go in the same file as everything else:

# infra/docker/.env — S3-compatible object storage
STORAGE_DRIVER=s3
S3_BUCKET=basalt-attachments
S3_REGION=auto
S3_ENDPOINT=https://s3.example-provider.com   # omit for AWS S3
AWS_ACCESS_KEY_ID=…
AWS_SECRET_ACCESS_KEY=…

A worked example, because "any value your provider accepts" is unhelpful the first time. With Hetzner Object Storage the region is the location code and the endpoint is derived from it:

STORAGE_DRIVER=s3
S3_BUCKET=basaltapp-attachments
S3_REGION=nbg1
S3_ENDPOINT=https://nbg1.your-objectstorage.com

Three things about the bucket itself, none of which can be changed comfortably later:

  • Private, not public. Every byte is served through the app (see below), so a public bucket buys nothing and costs the permission check: anyone who knows or guesses an object key would get the file without being a member of the workspace it belongs to.
  • Object Lock OFF. Basalt deletes objects — removing a workspace removes its attachments — and a bucket that refuses deletion turns an erasure request (GDPR Art. 17) into something you cannot honour. Object Lock can only be enabled when the bucket is created, so this is decided once and for good.
  • A key that reaches this bucket only. If you also keep backups in object storage, they belong in a different bucket with different credentials: the application never needs to read or write backups, and a key that can do both turns a compromised server into a compromised backup. That second bucket is also where Object Lock genuinely earns its keep.

Both backends serve through the app — a GET on the attachment route streams the bytes — so there is one serving path regardless of backend and the session cookie gates it. Presigned direct-to-S3 serving is a deliberate future optimisation, not something you can switch on.

s3 is the recommended choice for a real deployment, and local is safe. The difference is what happens to the bytes when the container is replaced: object storage does not care, whereas the local driver depends on the volume the compose file mounts. That volume is now shipped rather than left to the operator, so local no longer loses data — but it does tie your attachments to one machine's disk, and it is one more thing to remember in a backup routine. See Operations → Attachments.

Variable Default Reaches the container What it does
UNFURL on yes on or off. When off, the server refuses to fetch external URLs and link previews are unavailable instance-wide.

Read this before deciding. When somebody pastes a URL into a page, Basalt offers four ways to insert it: a plain link, the linked page's own title as the link text, a bookmark card (title, description, favicon, preview image), or an iframe embed. The last three need to know what is at that URL, and only that URL knows — so the server fetches it.

That means: your instance's IP address appears in the access log of a stranger's web server, next to a timestamp, next to a URL that somebody in your workspace was reading seconds earlier. For a company wiki that is your egress address plus a statement about what people inside are interested in, handed to an outsider by an editor affordance. It is not a leak of workspace content, and it is not nothing.

Basalt makes the exposure as small as it can be without removing the feature:

  • Pasting a link fetches nothing. The chooser defaults to the plain link, which is inserted immediately and costs no outbound request. A request happens only when a person explicitly picks "Title", "Card" or "Embed" — and the chooser says so, in one line, before they do.
  • One fetch per URL, not one per reader. What the card shows is written into the page (a basalt-embed fenced block, see the Markdown spec), so opening a page full of cards makes no outbound requests at all. Results are cached in memory for an hour on top of that, so a link doing the rounds of a workspace is fetched once.
  • The request carries nothing of yours. No cookies, no auth, no Referer, no workspace or user identifier. It announces itself as Basalt-Unfurl/1, so the operator of a site you link to can recognise and block it.
  • The address is checked and pinned. The fetch uses the same target policy as webhook delivery: https only, every resolved address must be globally routable (loopback, RFC 1918, CGNAT, link-local — including 169.254.169.254, the cloud metadata endpoint — are refused), the checked address is pinned into the connection so DNS rebinding cannot swap it, and redirects are never followed. Response size, content type and wall clock are all bounded, and nothing of the fetched page is stored or shown beyond five short metadata strings.
  • Each account may cause at most 20 new fetches a minute, so an instance cannot be used as a general-purpose anonymising request relay.

If none of that is acceptable for your deployment — an air-gapped instance, a network where outbound HTTP is an incident, or a workspace whose reading habits must not be observable — turn it off:

UNFURL=off

The editor then offers the plain link only; nothing else changes and nothing already in a page breaks (cards and embeds already stored keep rendering from what is written in the page).

What the reader's browser still does

Two things are worth stating because they are not covered by UNFURL:

  • A bookmark card shows a favicon and a preview image, and those are loaded by each reader's browser from the third-party host, with referrerpolicy="no-referrer". That is the same property an ordinary external image block in a page already has.
  • An iframe embed is a third-party page running inside the app. It is sandboxed without allow-same-origin (so it gets an opaque origin — no cookies, no storage, no way to recognise the reader) and without any top-navigation permission (so it cannot send the tab to a phishing page), with all permission-policy features denied. Sites that refuse framing are not offered as embeds in the first place, because the server sees their X-Frame-Options / frame-ancestors while fetching the preview.

Plans and limits

Variable Default Reaches the container What it does
DEFAULT_PLAN self_hosted yes Which plan a workspace is measured against when nothing else says otherwise: self_hosted, free, team or business.

Basalt has a plan model — storage, seats, per-file size and version-history retention, defined once in @basalt/core (plans.ts) and enforced by the server. On a self-hosted instance none of it applies: the default plan is self_hosted, whose every limit is "unlimited", so an instance you run on your own disks behaves exactly as it did before plans existed. You do not have to set this variable, and most operators never will.

It exists because the same artefact also runs as a hosted service, which sets DEFAULT_PLAN=free so that a newly created workspace starts on the free tier. A workspace can also be put on a specific plan individually (workspace.plan, assigned from the back office below), and that always wins over this default.

The limit check itself is never switched off — on an unlimited plan it simply passes. There is no "enforcement on/off" flag, for the same reason the sign-in rate limiter has none.

A limit never takes anything away. Being over one — by growing into it or by being moved to a smaller plan — refuses the next write of that kind and nothing else. Every page, file and version stays readable, searchable and exportable, and no member is removed. If you lower a limit on a workspace that is already above it, that workspace keeps everything it has and cannot add more until it is back under. Members can see exactly where they stand under Settings → Plan and usage.

The instance back office

Variable Default Reaches the container What it does
BASALT_OPERATORS unset yes Comma-separated user ids and/or email addresses. Whoever is listed may open /admin. Unset means nobody, and the whole area answers 403.

Running Basalt as a service for other people needs a place to see who those people are: which workspaces exist, how big they are, how many seats they use and what plan they are on. That is /admin, and this variable is the only thing that grants access to it.

# by user id (preferred), by address, or both
BASALT_OPERATORS=6d8f…,ops@example.com

Four things to know before you set it.

It is not a role. Workspace roles (owner, admin, …) are internal to one workspace and no combination of them reaches this area — a workspace owner gets the same 403 as anybody else. There is no operator table and no route that grants operator authority, so an account cannot escalate into it from inside the product: changing who is an operator means changing this variable and restarting. That is deliberate. It also means an attacker who fully compromises the application database still cannot make themselves an operator.

Unset denies. Not "the first user", not "any workspace owner". A self-hosted instance that never asked for a back office does not have one.

Prefer user ids. Registration is open (see below), so an address listed here before the matching account exists can be claimed by whoever signs up with it first. Once the account exists the address is taken and cannot be re-registered. Look the id up once and use that, or add the address only after the account is made.

A token cannot use it. Personal access tokens are refused on /admin regardless of their scopes, so operator authority cannot end up in a script's config file.

What an operator can and cannot see

This is a boundary, not an oversight. As the operator of a hosted Basalt you are a data processor (GDPR Art. 28): the workspace controls what its people write, and you process it only on their documented instruction. So the back office answers metadata only — workspace name and slug, creation date, member count per role, live page count, stored bytes, plan, last-activity timestamp, and the workspace's owners as billing contacts.

It cannot answer anything else. There is no route there that returns a page title, a page body, a comment or a search result, and no impersonation feature — not gated, not logged, not at all. A support door into customer content would have to be time-boxed, consented, logged and visible to the workspace it was used on; the last of those needs a surface inside the customer's own UI that does not exist yet, and three out of four is worse than none.

The one thing an operator can change is a workspace's plan. Every change is written to an append-only audit trail — who, when, from what, to what, and why — in the same database transaction as the change itself, so a plan change with nobody's name on it cannot exist. A database trigger refuses UPDATE, DELETE and TRUNCATE on that trail, so entries cannot be edited or removed by any code path, the back office included.

Assigning a smaller plan never deletes, hides or locks anything. It narrows what the workspace may add next; everything already there stays readable, searchable and exportable.

Email

Off unless you configure it, and an instance that never does loses nothing. With no mail settings the server boots normally, invitations still produce a copyable link exactly as they always have, and no background worker runs. This is the self-hosted default and it is a supported way to run Basalt forever.

Variable Default Reaches the container What it does
SMTP_HOST unset yes Hostname of the relay. Setting this and MAIL_FROM is what turns mail on.
SMTP_PORT from SMTP_SECURITY (587 / 465 / 25) yes Only needed for a relay on a non-standard port.
SMTP_SECURITY starttls yes starttls, tls or none. See below — this one is worth reading.
SMTP_USERNAME unset yes Omit both this and the password for a relay that wants no authentication.
SMTP_PASSWORD unset yes Sent only to the relay you configured, after the TLS upgrade. Never stored, never logged, never returned by a route.
MAIL_FROM unset yes The envelope sender and the From: header: basalt@example.com or Basalt <basalt@example.com>.
MAIL_DEFAULT_LOCALE en yes en or de — the language of a message nobody's browser asked for. See Which language a message is in.

These are the only settings that currently reach the container besides the core three, and they are forwarded with empty defaults, so adding them changed nothing for an existing stack: an empty value means "unset", and the server treats it exactly as if the variable had never been listed.

Turning it on

# infra/docker/.env
SMTP_HOST=smtp.example.com
SMTP_USERNAME=basalt@example.com
SMTP_PASSWORD=…
MAIL_FROM=Basalt <basalt@example.com>

Then restart the app container. Half of it is not enough — a host with no MAIL_FROM sends nothing at all — so the server logs one warning at boot naming exactly what is missing, rather than failing silently on every message.

Relaying through a mailbox you already have

The cheapest working setup is usually a mailbox at a hosting provider, used as an authenticated relay. It costs nothing extra and it borrows a sending reputation that already exists, which is the part you cannot buy quickly.

With netcup web hosting, for example:

# infra/docker/.env
SMTP_HOST=mxXXXX.netcup.net      # the exact name is in your CCP/WCP mail section
SMTP_PORT=465
SMTP_SECURITY=tls                # 465 is TLS from the first byte, not STARTTLS
SMTP_USERNAME=noreply@yourdomain.example
SMTP_PASSWORD=…                  # the mailbox password
MAIL_FROM=Basalt <noreply@yourdomain.example>

Two things this buys you, and one it costs:

  • No outbound port 25 to unblock. Providers commonly block it on VPS ranges and unblock it by request; a relay on 465 never needs it.
  • No reputation to build. A fresh VPS address has none, and large mailbox providers treat VPS ranges with suspicion. A confirmation mail that lands in spam is worse than one that bounces: nobody confirms, and it looks like nobody was interested.
  • Your SPF record has to authorise the relay, not your own server. Whatever the provider documents as its sending hosts goes into SPF for the domain in MAIL_FROM.

Running your own mail server on the same VPS is the other option and a much larger one: a PTR record, an unblocked port 25, DKIM signing, and weeks of reputation before large providers stop treating you as noise. Worth it at volume, rarely worth it for transactional mail.

Use an address you control the DNS for. MAIL_FROM is what SPF, DKIM and DMARC are checked against; an address borrowed from somewhere else produces mail that authenticates against nothing and lands in spam folders, which is worse than not sending. Basalt cannot check this for you.

SMTP_SECURITY, and why it is not a boolean

The two common answers are different protocols, and choosing the wrong one usually produces a hang rather than an error:

  • starttls (default, port 587) — connect in the clear, then upgrade before authenticating. If the relay does not offer STARTTLS, Basalt refuses to send rather than continuing unencrypted. That is deliberate: silently downgrading would hand your relay password to anyone on the path.
  • tls (port 465) — TLS from the first byte (implicit TLS / SMTPS).
  • none (port 25) — no encryption and no credentials on the wire. Only defensible for a relay you reach over a private network: a container on the same compose network, a sidecar, a mail server on localhost.

Certificates are verified with Node's defaults and there is no "accept self-signed" switch. An operator with an internal CA installs it; one who cannot has SMTP_SECURITY=none on a network they already trust. A verification escape hatch gets switched on once during setup and stays on forever.

There is no vendor SDK and no mail library: Basalt speaks SMTP over a socket. Every provider accepts it, it needs no account to test against, and it adds nothing to the dependency tree.

What gets sent

Three messages, and nothing else. There is no marketing mail, no digest, no notification stream, and no telemetry.

  1. An invitation, when a workspace admin invites an address. The admin still gets the link and can still deliver it themselves — the mail is an addition, never a replacement, and the invitation panel says which happened.
  2. A password reset. Without mail there is no reset flow at all and the sign-in screen says so, because a reset works by proving you can read a mailbox. See Recovering a locked-out account below.
  3. An invoice, when the billing module issues one, with the document attached as printable HTML. Only if the workspace has a billing email address; without one the invoice is still issued and readable in the app.

Every message goes out as plain text and HTML, and is delivered by a background worker — never inside the request. A relay that is slow, unreachable or silent can never make signing up, inviting somebody or finalising an invoice hang. Failures back off and retry for about six hours; a permanent refusal (a 5xx from the relay — "sender address rejected") is not retried at all, because it will be refused just as permanently the next time.

Which language a message is in

A message is written in the language of the request that caused it, from that request's Accept-Language header. A German admin's invitation is German. A reset link is in the language of the person who asked for it — the same person who will read it.

For anything no browser asked for — an invoice, which a payment provider's callback triggers — the language is MAIL_DEFAULT_LOCALE.

The recipient's own preference is deliberately not used, because for an invitation there is none: the whole point of the feature is inviting somebody who does not have an account yet. Basalt also stores no per-account language; the app's language setting is per device.

Recovering a locked-out account without mail

There is no back door, and that is not an oversight — an instance-level password reset would be a way into every workspace on the instance (see The instance back office, which deliberately cannot read workspace content). With no mail configured, the only way to restore access to an account is at the database:

docker compose -f infra/docker/compose.selfhost.yml exec db \
  psql -U basalt -d basalt -c "delete from account where user_id = '<id>'"

That removes the stored credential; the person then signs up again with the same address… which is refused, because the address is taken. In practice: if you run Basalt for other people, configure mail. If you run it for yourself, keep the password somewhere you will still have it.

What is in the database while a message waits

One row per queued message in mail_message, holding the recipient, the subject, the status and any error — a delivery log you can query. The body is encrypted with a key derived from AUTH_SECRET, because an invitation body contains the accept link and that link contains the token that is otherwise only ever stored as a hash. The body is dropped the moment the message is sent or given up on.

Rotating AUTH_SECRET makes any message still in the queue unsendable; it is marked failed rather than sent empty.

Billing and invoices

Off unless you turn it on, and absent from a self-hosted instance in every way that matters: no variable here is required, and with BILLING unset the routes answer 403, the provider callback answers 404, and nothing in the module runs.

The split is deliberate. The payment provider takes the money; Basalt issues the invoice — in your number range, from a record complete enough to render years later with the provider unreachable, changed, or gone. Card details are only ever entered on the provider's hosted pages; this system has no column for them and no route that returns one.

Switching it on

Variable Default What it does
BILLING off The whole module. on enables the routes and the provider callback.
BILLING_PROVIDER stripe stripe, or fake — a working provider that takes no money, for validating your number range and VAT settings with your accountant before a payment account exists.
STRIPE_SECRET_KEY unset Sent only to api.stripe.com, in an Authorization header. Never stored, never logged, never returned by a route, never in a URL.
STRIPE_WEBHOOK_SECRET unset Verifies the signature over the raw callback body.
BILLING_STRIPE_PRICES unset Which provider price each plan is sold as: team=price_123,business=price_456.

Who the invoice is from

Variable Default What it does
BILLING_SELLER_NAME unset Required to issue. Appears on every invoice.
BILLING_SELLER_ADDRESS unset Street lines, separated by | or newlines (max 4).
BILLING_SELLER_POSTAL_CODE unset
BILLING_SELLER_CITY unset Required to issue.
BILLING_SELLER_COUNTRY DE ISO 3166-1 alpha-2. Decides which VAT case applies.
BILLING_SELLER_VAT_ID unset Your VAT identification number — §14 Abs. 4 requires it on the invoice.
BILLING_CURRENCY EUR ISO 4217.

VAT

No rate is written anywhere in Basalt's source, and none is guessed. Rates change (Germany's 19 % has been 16 % twice in living memory), so the code knows the cases and reads the numbers from here. If a taxable sale has no configured rate, issuing fails loudly rather than inventing one.

Variable Default What it does
BILLING_DOMESTIC_TAX_RATE_BP 0 Your own country's rate in basis points: 19 % ⇒ 1900.
BILLING_OSS_TAX_RATES_BP unset Destination rates for EU consumers, AT=2000,FR=2000,NL=2100. A country you have not listed cannot be invoiced.

Four cases are recorded per invoice, decided from where the two parties are: domestic (your rate), EU business with a checked VAT ID (reverse charge, no VAT, with the §13b / Art. 196 note printed on the document), EU consumer (the customer's rate, One-Stop-Shop), and outside the EU (not taxable here).

The number range

The range is instance-wide, not per workspace — two customers' invoices are consecutive documents from one book. §14 Abs. 4 Nr. 4 UStG requires a number that is unique and issued by the author of the invoice; it does not require the range to be gap-free, and it explicitly allows several ranges. That is what lets these settings match a scheme you already run by hand.

Variable Default What it does
BILLING_NUMBER_SERIES default Counter identity. A different series is a different, independent range.
BILLING_NUMBER_PREFIX RE Leading literal. May be empty.
BILLING_NUMBER_SEPARATOR - Between segments. May be empty.
BILLING_NUMBER_YEAR yyyy none, yyyy or yy.
BILLING_NUMBER_WIDTH 5 Zero-padding. A counter past the width gets longer, never truncated.
BILLING_NUMBER_RESET yearly yearly restarts each calendar year (UTC); never counts on forever.
BILLING_NUMBER_START 1 The first number issued. Set it past your last manual invoice when migrating an existing range.

The defaults produce RE-2026-00001.

Two properties are enforced rather than hoped for:

  • A number is spent when an invoice is issued, never when it is drafted. A charge the provider drafts and then voids consumes nothing.
  • A number is never handed out twice, including by two processes in the same millisecond. Allocation happens inside the transaction that writes the invoice, so a failed issuance consumes nothing either — and the database refuses a duplicate number independently of all of that.

What is still to do before real keys

Everything above runs today against BILLING_PROVIDER=fake. With real Stripe credentials, three things remain:

  1. Create the prices in your Stripe account and map them in BILLING_STRIPE_PRICES. Point a Stripe webhook endpoint at POST /api/v1/billing/webhook and subscribe it to customer.subscription.*, invoice.created, invoice.finalized, invoice.paid and invoice.voided. Put its signing secret in STRIPE_WEBHOOK_SECRET.
  2. VAT ID validation is not wired. A number entered by a customer is stored as unchecked, and an unchecked number does not get reverse charge — it is taxed instead, because VAT that is wrongly not charged is yours to pay. Only a check against the EU VIES service may set it to valid, and nothing in Basalt performs one yet.
  3. Sending the invoice needs mail configured. With SMTP_HOST and MAIL_FROM set (see Email above), an issued invoice is mailed to the workspace's billing address with the document attached as printable HTML. Without them — or without a billing email on the workspace — the invoice is still issued and still readable and printable in the app; the reason it was not sent is recorded on the provider event.

Also worth knowing: the subscription this module mirrors is not the entitlement. workspace.plan decides what a workspace may do; a provider outage, a replayed callback or a mis-parsed payload therefore cannot silently change it.

Monitoring

Variable Default Reaches the container What it does
METRICS on yes Whether GET /metrics exists at all. off makes it a 404.
METRICS_TOKEN unset yes The bearer token /metrics requires. Unset means loopback callers only. At least 32 characters.
BASALT_VERSION baked into the image yes What this build calls itself, published as the version label of basalt_build_info. A published image already carries its own — the release workflow bakes in the version tag (1.4.0) or, on a main build, main-<short sha>. It falls back to unknown only for an image somebody built by hand. Setting it here overrides what the build said, which is occasionally useful and usually a way to mislabel your own dashboards.

The setting to understand is METRICS_TOKEN, and the important part is what happens when you leave it unset.

/metrics is a Prometheus scrape endpoint. It publishes no page content, no names and no addresses — the labels are route patterns and closed enums, never data (see Operations → Monitoring). What it does publish is per-route request and error rates, failed sign-in counts, queue depths, socket counts and the exact version you are running. On a self-hosted instance that is one reverse proxy forwarding /, an unauthenticated /metrics is therefore a free reconnaissance feed on the public internet, and nobody would notice.

So without a token the endpoint answers only a loopback peer — a shell inside the container, or a sidecar sharing its network namespace:

docker compose -f infra/docker/compose.selfhost.yml exec app \
  wget -qO- http://127.0.0.1:3000/metrics | head

That judgement uses the raw socket address and deliberately ignores X-Forwarded-For, which any client can write. With TRUST_PROXY set, believing that header would let anyone on the internet claim to be 127.0.0.1.

Set the token to scrape from anywhere:

METRICS_TOKEN=$(openssl rand -hex 32)

It is then required from everywhere, loopback included. An exception for local callers would be a bypass, and bypasses get used by accident.

All three go in infra/docker/.env like everything else. METRICS=on is the default, so a scrape from inside the container works out of the box; only the token needs writing down.

The endpoint is unreachable with a personal access token, and that is structural rather than a rule: it lives at /metrics, outside /api/v1, so the token classifier never applies to it. A PAT is a workspace-scoped credential that lives in somebody's config file; instance telemetry is not workspace-scoped and does not belong to it.

Tracing

Optional, off, and absent from the process until you set the first variable.

Variable Default Reaches the container What it does
OTEL_EXPORTER_OTLP_ENDPOINT unset yes The switch. Base URL of an OTLP collector, e.g. http://collector:4318. Spans are POSTed to <endpoint>/v1/traces. Unset means no tracing whatsoever.
OTEL_EXPORTER_OTLP_HEADERS unset yes key=value,key2=value2, values percent-decoded. Where a hosted backend's API key goes. Sent only to the endpoint above; never logged.
OTEL_SERVICE_NAME basalt yes The service.name on every span.
OTEL_TRACES_SAMPLER_ARG 1 yes Fraction of traces recorded, 01. Lower it if the volume costs you money at your backend.

You do not need this. /metrics above answers the questions a pager asks — what is failing, how often, how slowly — with no second piece of software. A trace answers a different and narrower one: this single request took 900 ms; which part of it did? If you have never wanted to ask that, leave all four unset and nothing about your instance changes. There is no reduced feature set, no warning in the log, and no cost: with no endpoint configured Basalt does not even load its tracing code.

Turning it on needs somewhere to send spans, which — unlike metrics — means a second deployable. Any OTLP collector works: the OpenTelemetry Collector, Jaeger, Tempo, or a hosted backend that speaks OTLP.

# infra/docker/.env
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318
OTEL_TRACES_SAMPLER_ARG=0.1   # 10% of requests, on a busy instance

Six spans exist, and the list is deliberately short — see Operations → What is traced. Notably, there is no span per database statement: that is the setting that makes tracing expensive, and the questions it answers are already answered by basalt_db_pool_acquire_seconds.

Two limits worth knowing before you wire a collector up:

  • Basalt speaks OTLP over HTTP with a JSON body, and nothing else. No gRPC, no protobuf. OTEL_EXPORTER_OTLP_PROTOCOL is not read, and no OTEL_* variable beyond the four above has any effect. Every collector accepts http/json on /v1/traces, so this is a limit on your configuration, not on your choice of backend.
  • An inbound traceparent header is ignored. A trace always starts inside Basalt. The self-host guide forwards / to the app, so that header is attacker-controlled at the edge, and honouring it would let a stranger override your sampling rate — with OTEL_TRACES_SAMPLER_ARG=0.01 set to keep a bill down, a script could still have 100% of its own requests recorded and billed. If you run Basalt behind a gateway that traces and want the two joined, that is a change with a trust boundary to decide first.

Spans carry no workspace, page, user, title, address or URL — the same disclosure boundary the metrics labels hold to, for the same reasons, and asserted against a real collector in the test suite. So a trace tells you where the time went, never whose request it was. That question belongs to the audit log and the back office, which are authenticated.

If the collector is down, spans are dropped and a warning appears in the log at most once a minute. Nothing about serving requests depends on it.

Serving the web app

Variable Default Reaches the container What it does
SERVE_STATIC_DIR unset set by the image (/app/apps/web/dist) When set, the server also serves the built SPA from this directory and falls back to index.html for non-/api/ GETs. Unset in development, where Vite serves the app.
NODE_ENV unset set by the image (production) Standard Node convention. Basalt's own rate limiting deliberately does not depend on it.

What is not configurable

Worth knowing before you go looking:

  • Registration cannot be turned off. There is no invite-only mode and no allowlist. See Operations → Closing registration.
  • Email is off until you configure it (see Email above), and an instance that never does keeps working: invitations are links you copy out of the app and deliver yourself. What has no configuration at all is address verification — a new account is usable immediately, and no mail is sent to confirm the address.
  • There is no admin user or superuser inside a workspace. Authority there is per workspace: the person who creates one owns it, and no instance-level account can read into it. The instance operator (BASALT_OPERATORS, above) is a different thing entirely — it sees metadata about workspaces and cannot read what is in them.
  • Migrations are not configurable. They run on boot, advisory-locked and forward-only.