Environment variables
A single .env file at the repository root is used by both apps:
- the server loads it at the very top of
apps/server/src/main.ts(before any entity import — see Driver locking); - the frontend scripts wrap Next.js with
dotenv -e "${ENV_FILE:-../../.env}".
ENV_FILE names another file, for a build that runs outside the repository tree or keeps its
secrets under /etc: ENV_FILE=/etc/vocab-bloom-hub/.env yarn start. The server logs which file
it loaded and exits with code 1 when an explicitly named file cannot be read (a missing default is
only a warning: the variables may come from the process environment).
Nota
Variables already present in the process environment always win over the file (dotenv never overrides them).
Importante
Use an absolute path — the server resolves a relative one from its working directory, the
frontend scripts from apps/frontend.
Variables
Nota
The admin credentials were once USERNAME / PASSWORD; only ADMIN_USERNAME /
ADMIN_PASSWORD are read now (a bare USERNAME collides with the variable most operating
systems set to the current user).
| Variable | Required | Default | Used by | Description |
|---|---|---|---|---|
ADMIN_USERNAME | yes | — | server | Admin login. Together with ADMIN_PASSWORD it derives the login-proof key and the JWT signing secret (see authentication.md). |
ADMIN_PASSWORD | yes | — | server | Admin password. The server refuses to start when it is missing or blank. |
DATABASE_URL | in production | SQLite fallback (dev only) | server | Database URL; the scheme selects the driver. postgres://user:pass@host:5432/db (or postgresql://) runs Postgres with the schema managed by migrations (see database.md). sqlite:<path> (e.g. sqlite:./my.sqlite, sqlite::memory:) runs better-sqlite3 with synchronize — used by the browser e2e tests for an isolated database. Any other scheme fails startup. When absent in development, TypeORM falls back to dev.sqlite at the repo root. |
DB_POOL_SIZE | no | 10 | server | Maximum connections in the Postgres pool (the pg driver's max; ignored on SQLite — it has no pool). Raise it when the pool metrics (vbh_db_pool_connections, see observability.md) show clients waiting; on a managed Postgres keep replicas × DB_POOL_SIZE under the instance's connection limit, leaving headroom for migrations and ad-hoc sessions — and, on an instance that holds several datasets, for the reads of the ones that are not served: up to four connections each (datasets.md). A whole number, at least 1; anything else fails startup. |
DB_POOL_IDLE_TIMEOUT | no | 10 | server | Seconds an idle Postgres pool connection is kept before it is closed (the pg driver's idleTimeoutMillis). 0 keeps idle connections open forever. Whole seconds; anything else fails startup. |
SERVER_PORT | no | 3010 | server, frontend, site | Port the NestJS API listens on. The frontend and site also read it as the fallback target of their /api/* forwarding route (core/apiProxy.ts) when API_INTERNAL_URL is unset. Under docker compose it is the host port the API is published on, 3240 by default. |
FRONT_PORT | no | 3000 | frontend, server | Port of the admin UI when started through the workspace scripts (yarn front:dev / yarn start:front) — wired into PORT through next.config.ts, so it covers next dev and next start alike. The server also reads it for the development CORS default. The Docker image and the systemd/PM2 examples set PORT directly instead. Under docker compose it is the host port of the admin UI, 3241 by default. |
PORT | no | 3000 / 3020 | frontend, site | What next start and the standalone server.js actually listen on. docker-compose.yml, the Dockerfiles and the systemd/PM2 examples set it explicitly; the workspace scripts derive it from FRONT_PORT / SITE_PORT, so a native start usually never sets it by hand. |
SITE_PORT | no | 3020 | site, server | Port the website listens on (yarn site:dev / yarn start:site; the host port of the site compose service). See deployment/docker.md. Under docker compose the host port defaults to 3242. |
NEXT_PUBLIC_SITE_URL | no | http://localhost:3020 | site | The public origin of the website, for the absolute URLs of its sitemaps (sitemap.xml, sitemap-words.xml), robots.txt, the canonical / hreflang links and the OpenGraph / Twitter social cards (metadataBase). Inlined at build time, so under Docker it is a build argument (docker-compose.build.yml) and has no effect on a pulled image: the published site image is built for https://vocab-bloom-hub.com (deployment/docker.md). |
SITE_VERIFICATION_GOOGLE | no | — | site | The token of the google-site-verification meta tag — the "HTML tag" method of Google Search Console — for verifying the website's origin. Like NEXT_PUBLIC_SITE_URL it is read at build time (the start page that carries the tag is prerendered), so under Docker it is a build argument of docker-compose.build.yml; a pulled image carries none. DNS verification needs no variable (deployment/docker.md). |
SITE_VERIFICATION_BING | no | — | site | The token of the msvalidate.01 meta tag of Bing Webmaster Tools, read at build time like SITE_VERIFICATION_GOOGLE. |
SITE_VERIFICATION_YANDEX | no | — | site | The token of the yandex-verification meta tag of Yandex Webmaster, read at build time like SITE_VERIFICATION_GOOGLE. |
CONTENT_ROOT | no | the repository root | site | Root the website reads the repository content from at build time (docs/*.md, the READMEs, the OpenAPI document; apps/site/src/content/repo.ts). Defaults to two levels above the site app — the checkout layout, which the Dockerfile also arranges — so it only needs setting for a build outside that layout. |
NEXT_PUBLIC_BASE_API_URL | no | /api | frontend, site | Base URL the browser uses for API requests (the admin UI, the site's playground and search). Inlined at build time (NEXT_PUBLIC_ prefix), so a change requires a rebuild. A relative value (the default) is resolved against the page origin — "the API is under this origin", which the reverse proxy provides. Server-side rendering uses it too unless API_INTERNAL_URL is set, and then it must be absolute. |
API_INTERNAL_URL | no | — (NEXT_PUBLIC_BASE_API_URL) | frontend, site | Runtime address the frontend and site processes themselves use for the API, without the proxy: http://server:3010/api in docker-compose.yml. Used for server-side rendering (the admin pages, the site's word pages) and for forwarding /api/* requests that reach the frontend or site origin (no reverse proxy in front); for the forwarding it falls back to http://127.0.0.1:<SERVER_PORT>/api. Never sent to the browser. |
CORS_ORIGINS | no | http://localhost:<FRONT_PORT>, http://localhost:<SITE_PORT> | server | Comma-separated list of allowed CORS origins, e.g. https://admin.example.com,https://staging.example.com. |
TRUST_PROXY | no | — (headers ignored) | server | Express trust proxy setting: a hop count (1 for one reverse proxy), loopback, an IP / CIDR list, or true. Makes rate limits and logs use the client address from X-Forwarded-For. Set it only behind a proxy; see deployment/reverse-proxy.md. |
METRICS_ENABLED | no | false | server | Serves Prometheus metrics at METRICS_PATH (process, HTTP by route template, search tiers, dictionary size, transfers, Postgres pool). Keep the endpoint off the public internet; see observability.md. |
METRICS_PATH | no | /metrics | server | Path of the metrics endpoint, outside both API surfaces. |
PUBLIC_API_ENABLED | no | true | server | Serves the public read-only prefix /api/v1. false makes those routes answer 404 (admin-only instance). See api.md. |
ADMIN_API_ENABLED | no | true | server | Serves the admin surface (/api/en, /api/settings, /api/auth). false makes those routes answer 404 (public-only instance); disabling both surfaces fails startup. |
PUBLIC_API_RATE_LIMIT | no | 100/60 | server | Requests per client IP allowed on the whole /api/v1 prefix, as <requests>/<seconds>. A batch lookup counts as one request. Anything else fails startup. |
INTERNAL_API_TOKEN | no | — (no exemption) | both | A secret shared by the server and the website. The site's server-side requests to the API — the word pages, the headword walk behind sitemap-words.xml and /word/browse — carry it as X-Internal-Token and are not counted against PUBLIC_API_RATE_LIMIT; without it they share one budget with the walk, and a word page rendered during it fails. 16+ characters (openssl rand -hex 32), shorter fails startup. Set it whenever the site profile runs. |
PUBLIC_API_CACHE_MAX_AGE | no | 3600 | server | Seconds a shared cache (browser, CDN, reverse proxy) may keep a public GET answer: Cache-Control: public, max-age=<value>, stale-while-revalidate=<value> on every successful /api/v1 GET (a shared cache may serve the stale answer for as long again while it revalidates). 0 sends public, no-cache (revalidate on each use; the ETag makes that a bodiless 304). Anything but a non-negative integer fails startup. See api.md. |
SUGGESTIONS_RATE_LIMIT | no | 5/3600 | server | Reports per client allowed on POST /api/v1/suggestions (the Report a mistake form of the word pages), as <requests>/<seconds> — a budget separate from PUBLIC_API_RATE_LIMIT. Anything else fails startup. See api.md. |
DICTIONARY_IMPORT_DIR | no | — (server-side datasets disabled) | server | Folder the dictionary import may read datasets from (zip archives or dataset folders in the export format, one level deep), e.g. a mounted volume. Paths in import requests are resolved inside it only. Unset, the Archive tab of an import offers the upload only, no server-side list. See offline-import.md. |
DICTIONARY_AUTO_IMPORT | no | false (true in docker-compose.yml) | server | Load the dictionary by itself on first start: when no dataset version is recorded in the settings, the server imports the newest dataset in DICTIONARY_IMPORT_DIR or, without one, the published dataset from HuggingFace — in the background, with progress in the log; GET /api/ready answers 503 importing meanwhile and 503 import_failed after a failure (the next start retries). A recorded version means nothing happens. See deployment/docker.md. |
DICTIONARY_DATASET_VERSION | no | — (the moving main) | server | Pins the automatic first-start import to one revision of the published dataset: a version tag of the HuggingFace repo (each published revision is tagged with its manifest.version, see data.md), a branch or a commit sha. Manual imports pick a revision in the import of the project's dataset instead. This setting does not select an OpenGloss release; its download links are pinned in the catalog. |
LOG_LEVEL | no | debug in development, else log | server | Minimum server log level: verbose / debug / log / warn / error / fatal (pino's trace / info accepted too). Unknown values fall back to the default. See observability.md. |
LOG_FORMAT | no | json in production, else pretty | server | Shape of the log lines on stdout: json — one JSON object per line for a log collector (request id, method, path, status, duration; errors with their stack) — or pretty for a terminal. Anything else fails startup. See observability.md. |
ENV_FILE | no | the root .env | both | Path of the environment file to load instead of the repository root .env (absolute path). The server exits when the named file cannot be read; see above. |
AUDIT_RETENTION_DAYS | no | 90 | server | Days the journal of what was done on the instance is kept — imports, settings, switches of the dataset, verdicts on reports (the Events tab of the History page, GET /api/en/audit); older rows are deleted on start and daily. 0 keeps them forever. Whole days; anything else fails startup. The history of the edits of the dictionary is a part of the data and is kept for good (datasets.md). |
UPDATE_CHECK | no | true | server | The update notice of the admin UI: the server asks GitHub for the latest stable release a few times a day (cached six hours) and the admin UI says when it is newer than the running version — a notice only, the instance never updates itself. The same switch covers the notice of a newer file of a source on the datasets page: the sources of the installed datasets are asked once a day (datasets.md). false disables update-check requests, not explicit installation downloads or the first-start import. OpenGloss is pinned in the catalog and has no newer-release check. Anything but a boolean word fails startup. See upgrading.md. |
SHUTDOWN_TIMEOUT | no | 30 | server | Seconds a graceful stop may take after SIGTERM / SIGINT: the listener closes, requests in flight finish, the database pool closes. Past the budget the server logs forcing exit and exits with code 1 instead of waiting for the process manager's SIGKILL. Whole seconds, at least 1; anything else fails startup. See deployment/README.md. |
NODE_ENV | no | — | both | development enables debug logging and pretty logs; production requires a postgres:// DATABASE_URL, disables the Swagger UI at /api, logs JSON and warns at every admin login that arrives over plain http (the cookie is secure whenever the request came over https, in any mode). Schema management does not depend on it: SQLite always synchronizes, Postgres always uses migrations. |
Semantic search (optional)
SEMANTIC_SEARCH_ENABLED defaults to false and independently enables the semantic API
and website mode. Starting a model or importing vectors does not enable search. The website
reads this switch from its API; it does not need its own environment variable.
| Variable | Default | Purpose |
|---|---|---|
EMBEDDINGS_URL | Native: http://localhost:11434; Compose: http://embeddings:11434 | Ollama HTTP(S) origin, including a remote LAN host. |
EMBEDDINGS_MODEL | embeddinggemma-2:latest | Exact model name used by the imported profile. |
EMBEDDINGS_REVISION | unset | Required for enabled search: immutable 64-character model digest. |
EMBEDDINGS_DIMENSIONS | 768 | Imported profile/query vector dimension (1–2,000). |
EMBEDDINGS_IMAGE | ollama/ollama:0.40.8 | Compose-only runtime image; independent of the model digest. |
See semantic-search.md for external vector files, API behavior,
limits, unavailable errors and Docker/remote configuration.
The embeddings Compose profile starts a private Ollama service with persistent model storage;
its model download and PostgreSQL extension activation are explicit operator actions.
Startup validation
The server validates its configuration before Nest is created (assertRequiredConfig in
apps/server/configuration.ts) and exits with code 1 and a clear error message when:
ADMIN_USERNAMEorADMIN_PASSWORDis missing or blank — this protects against the silent fail-open where an unloaded.envleaves the credentials undefined and the password hashes as the literal string"undefined". TheADMIN_prefix is deliberate: a bareUSERNAMEis commonly set by the OS to the current system user and would silently satisfy the check;NODE_ENV=productionandDATABASE_URLis not apostgres://connection string — production never runs on SQLite silently;DATABASE_URLis set but its scheme is not recognized (postgres://,postgresql://orsqlite:<path>) — guessing the driver would silently switch how the schema is managed (auto-DDL vs migrations);ENV_FILEnames a file that cannot be read, orSHUTDOWN_TIMEOUT,LOG_FORMAT,AUDIT_RETENTION_DAYS,UPDATE_CHECK,PUBLIC_API_RATE_LIMIT,PUBLIC_API_CACHE_MAX_AGE,INTERNAL_API_TOKEN,SUGGESTIONS_RATE_LIMIT,DB_POOL_SIZE,DB_POOL_IDLE_TIMEOUThold values that do not parse.
The resolved database driver is logged at startup: Database: Postgres (DATABASE_URL) or
better-sqlite3 (<path>); on Postgres the next line reports the resolved pool settings
(Database pool: up to N connections …).
Nota
Changing the pool variables requires a server restart — they are read once, when the pool is created.
Database driver locking
Entity column types are resolved at import time inside TypeORM decorators
(checkIsPostgres()), so the driver choice is locked at the first call and stays consistent for
the lifetime of the process. assertDatabaseDriverConsistent() fails the startup when entities
were imported before the environment was loaded (e.g. a custom entry point that forgets to load
.env first). Tests that need the SQLite types import
__tests__/helpers/clearDatabaseUrl.ts before any entity for the same reason.
Example .env
The template is .env.example at the repository root: what docker compose
needs, with every optional variable commented out next to its default. The minimal files for a
native production start and for development are in the README's
getting started.