Skip to content
getgeolens.com

Configuration Reference

The operator-facing environment variables for GeoLens, their defaults, and descriptions. Unless noted otherwise, set these in the .env file at the project root. A few quota and policy settings (flagged below) are managed only from the admin UI and are not read from .env.

Many of the remaining settings are admin-overridable: the .env value is only the default, and a value saved from the admin UI takes precedence for as long as it is stored. That applies to REGISTRATION_ENABLED, ACCESS_TOKEN_EXPIRE_MINUTES, REFRESH_TOKEN_EXPIRE_DAYS, LOG_LEVEL, LOG_JSON, CORS_ALLOWED_ORIGINS, UPLOAD_MAX_SIZE_MB, UPLOAD_ALLOWED_EXTENSIONS, TILE_CACHE_TTL, PUBLIC_APP_URL, PUBLIC_API_URL, and the AI rows marked below. If an .env edit appears to have no effect, check the matching admin tab — or set ENV_ONLY_CONFIG=true to lock every admin-overridable setting to its environment value. LOG_JSON is an exception: both the log format and the security posture below are read from the environment value when the process starts, so saving it in the admin UI does not change either one.

VariableDefaultRequiredDescription
POSTGRES_DBgeolensYesPostgreSQL database name
POSTGRES_USERgeolensYesPostgreSQL superuser username
POSTGRES_PASSWORDNone (required)YesPostgreSQL superuser password. Generate with openssl rand -base64 24.
POSTGRES_HOSTlocalhostNoDatabase hostname (application default localhost). Docker Compose sets this to db, the database service name.
POSTGRES_PORT5432NoDatabase port (internal). The host-mapped port is configured separately.
VariableDefaultRequiredDescription
JWT_SECRET_KEYNone (required)YesSecret key for signing JWT tokens. Generate with openssl rand -hex 32. Must be at least 32 chars; the boot validator also rejects six well-known public placeholders (change-me, secret, etc.).
JWT_ALGORITHMHS256NoJWT signing algorithm
ACCESS_TOKEN_EXPIRE_MINUTES15NoJWT access-token lifetime in minutes
REFRESH_TOKEN_EXPIRE_DAYS7NoJWT refresh-token lifetime in days
GEOLENS_ADMIN_USERNAMENone (required)YesUsername for the automatically created admin account
GEOLENS_ADMIN_PASSWORDNone (required)YesPassword for the initial admin account
REGISTRATION_ENABLEDfalseNoWhether self-registration is enabled. When false, only admins can create users.
EMAIL_VERIFICATION_REQUIREDtrueAdmin UI onlyManaged on the admin Auth tab — not read from .env. The value shown is the built-in default. Require new self-registered accounts to verify their email address before first login. Only relevant when REGISTRATION_ENABLED=true; requires a configured SMTP channel to deliver the verification email.
LANDING_FIRSTfalseNoRedirect unauthenticated visits to / to the login page instead of the catalog (admin-overridable, “Login-as-Landing Page”). Useful for demo or lead-capture front doors.
PASSWORD_MIN_LENGTH12NoMinimum password length, enforced at every password entry point (register, change-password, admin create, SAML->local conversion).
PASSWORD_REQUIRE_CLASSES3NoNumber of character classes required out of 4 (lowercase, uppercase, digit, symbol). Accepts 1 to 4.
VariableDefaultRequiredDescription
UPLOAD_MAX_SIZE_MB500NoMaximum upload file size in megabytes
UPLOAD_STAGING_DIR/app/stagingNo (Compose-pinned)Directory for temporary file storage during ingestion/export. Both Compose stacks pin this to the literal /app/staging on the api and worker services, and titiler resolves the same path from the built-in default with no env passthrough at all. All three share the upload_staging volume at that fixed mount target, so setting it in .env has no effect. Override it only for a bare-metal/non-Compose run. Must be writable by the API runtime user (uid:gid 1001:1001).
UPLOAD_ALLOWED_EXTENSIONS.zip,.gpkg,.geojson,.json,.csv,.tif,.tiff,.xlsx,.xls,.parquet,.fgb,.kml,.kmzNoComma-separated list of allowed file extensions for upload. Note: an admin-saved override in Admin → Storage takes precedence over this default — existing deployments that customized the list must add newer extensions (such as .parquet, .fgb, .kml, .kmz) there
PRESIGNED_MULTIPART_THRESHOLD_MB100NoFiles larger than this (MB) use multipart presigned S3 URLs. Only applies when STORAGE_PROVIDER=s3.
INGEST_HTTP_TIMEOUT_SECONDS300NoGDAL_HTTP_TIMEOUT passed to ogr2ogr during remote-fetched ingest. Raise for very large datasets; lower for fail-fast ingest debugging.
URL_IMPORT_FETCH_MAX_SECONDS1800NoHow long one File URL import may spend downloading, in seconds. Accepts 30 to 86400; a value outside that range is refused at startup. The download runs on the worker with no proxy or client deadline over it, so this is the only ceiling.
MAX_STORAGE_BYTES_PER_USER0Admin UI onlyManaged on the admin Storage tab — not read from .env. Per-user storage quota in bytes; 0 = unlimited. The value shown is the built-in default.
MAX_DATASETS_PER_USER0Admin UI onlyManaged on the admin Storage tab — not read from .env. Per-user dataset-count quota; 0 = unlimited. The value shown is the built-in default.

UPLOAD_STAGING_DIR writability requirement

Section titled “UPLOAD_STAGING_DIR writability requirement”

GeoLens validates staging writability at startup and before export execution. Both UPLOAD_STAGING_DIR and ${UPLOAD_STAGING_DIR}/exports must allow write access for the API runtime user.

Quick validation command. It runs as uid 1001 deliberately: the api container starts as root and drops to 1001:1001 inside its own entrypoint, so an unqualified docker compose exec would test root’s access rather than the runtime user’s and pass on the very misconfiguration you are looking for.

Terminal window
docker compose exec -u 1001:1001 api sh -lc '\
dir=${UPLOAD_STAGING_DIR:-/app/staging}; \
mkdir -p "$dir/exports" && \
touch "$dir/.geolens-write-test" "$dir/exports/.geolens-write-test" && \
rm -f "$dir/.geolens-write-test" "$dir/exports/.geolens-write-test"'

The api entrypoint already chowns the staging directory to 1001:1001 on every boot while it is still root, and ignores any error from that chown; its own boot probe then runs as root, which passes on exactly the root-writable, appuser-unwritable state the command above is designed to catch. So a failing probe on a running container means the boot-time chown did not take effect — on an NFS root-squash mount, for instance — and restarting will not change that. Fix ownership on the mounted path directly with docker compose exec -u 0 api chown -R 1001:1001 /app/staging, then restart the API container. Under Compose, pointing UPLOAD_STAGING_DIR somewhere else is not an option: the path is pinned (see the table above).

VariableDefaultRequiredDescription
PROCRASTINATE_SCHEMAcatalogNoPostgreSQL schema for the Procrastinate job queue tables
INGEST_JOBS_RETENTION_DAYS30NoDays to retain finished ingest job records before pruning; 0 disables pruning.
VariableDefaultRequiredDescription
PUBLIC_APP_URLhttp://localhost:8080NoBrowser-facing app URL. Used for share links and OAuth redirect URIs.
PUBLIC_API_URLhttp://localhost:8080/apiNoExternally-reachable API base URL. Used in OGC self/collection/next link hrefs. That default is the Docker Compose one; outside Compose the application falls back to the request origin, then to http://localhost:8000. Set it explicitly on any deployment behind a proxy or a real hostname — OAuth flows refuse the request-derived fallback outright.
PUBLIC_BASE_URLNoneNoDeprecated. Will be removed in a future release. Legacy alias for PUBLIC_API_URL. Use PUBLIC_API_URL instead. The application logs a deprecation warning at startup when this is set.
VariableDefaultRequiredDescription
CORS_ALLOWED_ORIGINS""NoComma-separated list of exact origins allowed to make credentialed cross-origin API requests (a literal * is rejected). Required when the frontend is served from a different domain than the API. Anonymous reads of the standards and tile routes are unaffected; see Settings -> Network.
VariableDefaultRequiredDescription
TILE_CACHE_TTL300NoTile cache TTL in seconds
TILE_SIGNING_SECRETNone (falls back to JWT_SECRET_KEY)NoSecret for signing tile request URLs. Set separately when you want to rotate tile secrets without invalidating JWT tokens.
CDN_BASE_URLNoneNoCDN origin URL for tile delivery. When set, the frontend requests tiles from this URL instead of the API.
VariableDefaultRequiredDescription
LOG_JSONfalseNoOutput logs in structured JSON format. Recommended for production. Also acts as the legacy security-posture switch: when ENVIRONMENT is unset (the default), LOG_JSON=true additionally hides /api/docs and /api/redoc and sets the Secure flag on the OAuth session cookie. Set ENVIRONMENT explicitly to decouple the two — see Deployment / security posture.
LOG_LEVELINFONoLog level. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL.
VariableDefaultRequiredDescription
STORAGE_PROVIDERlocalNoStorage backend for uploaded files. Options: local, s3, azure.
S3_ENDPOINTNoneNoS3-compatible endpoint URL. Leave unset for AWS S3. For MinIO: http://minio:9000.
S3_BUCKETNoneWhen s3S3 bucket name.
S3_ACCESS_KEY_IDNoneWhen s3, without ambient credentialsS3 access key ID. Leave both keys unset when the pod or task supplies ambient AWS credentials (IRSA, EKS Pod Identity, or ECS container credentials) — see Deploy on EKS. A half-configured pair is always rejected at boot.
S3_SECRET_ACCESS_KEYNoneWhen s3, without ambient credentialsS3 secret access key.
S3_REGIONus-east-1NoS3 region.
S3_ALLOW_HTTPfalseNoAllow HTTP (non-TLS) connections to S3 endpoint. Enable for local MinIO.
S3_ADDRESSING_STYLEautoNoS3 addressing style. Options: auto, path, virtual. Use path for MinIO.

Set STORAGE_PROVIDER=azure and configure a container plus one authentication path: either a connection string, or an account URL with an access key (leave the key unset to use managed identity / Entra ID).

VariableDefaultRequiredDescription
AZURE_STORAGE_CONTAINERNoneWhen azureBlob container name (e.g. geolens-prod).
AZURE_STORAGE_CONNECTION_STRINGNoneOne auth pathFull connection string (also used for Azurite). Provide this or AZURE_STORAGE_ACCOUNT_URL.
AZURE_STORAGE_ACCOUNT_URLNoneOne auth pathAccount blob endpoint, https://<account>.blob.core.windows.net.
AZURE_STORAGE_ACCOUNT_KEYNoneWith account URL + key authStorage account access key. Leave unset (with AZURE_STORAGE_ACCOUNT_URL set) to authenticate via managed identity / Entra ID.
VariableDefaultRequiredDescription
DATABASE_URL_OVERRIDENoneNoFull PostgreSQL connection URL for managed databases (RDS, Cloud SQL). Overrides individual POSTGRES_* variables.
DATABASE_SSL_MODEpreferNoDatabase SSL mode. Options: disable, prefer, require, verify-full.
DATABASE_SSL_CA_CERTNoneWhen verify-fullPath to CA certificate file for database SSL verification.
DATABASE_POOL_PRE_PINGtrueNoEnable connection pool pre-ping to detect broken connections before use. Adds slight latency per checkout. Set to false only if you need to disable this for a specific environment.
DB_USE_EXTERNAL_POOLERfalseNoEnable external connection pooler mode (PgBouncer, RDS Proxy). Disables prepared statements.

These variables control the SQLAlchemy connection pool. Ignored when DB_USE_EXTERNAL_POOLER is true.

VariableDefaultRequiredDescription
DB_POOL_SIZE10NoMaximum number of persistent connections in the pool.
DB_MAX_OVERFLOW3NoMaximum number of additional connections beyond DB_POOL_SIZE. Tuned down from SQLAlchemy’s default to fit the connection budget below.
DB_POOL_TIMEOUT30NoSeconds to wait for a connection from the pool before raising an error.
DB_STATEMENT_TIMEOUT_SECONDS300NoQuery deadline for the API process, in seconds; 0 disables it. Distinct from DB_POOL_TIMEOUT: that one bounds how long a request waits for a connection, this one bounds how long a statement runs once it has one, so one pathological plan can’t pin a pool slot indefinitely. Applies to every API transaction; the worker is deliberately excluded, since it legitimately runs a single statement for minutes while indexing or reprojecting a freshly ingested table. Raise it (or set it to 0) if a legitimate API workload needs longer than five minutes.
DB_POOL_RECYCLE1800NoSeconds after which a connection is recycled (replaced). Prevents stale connections with managed databases.
TILE_POOL_MIN_SIZE2NoMinimum connections in the dedicated asyncpg tile query pool.
TILE_POOL_MAX_SIZE10NoMaximum connections in the dedicated asyncpg tile query pool.
UVICORN_WORKERS2 (release stack); 1 (source-build stack)NoUvicorn worker processes for the API. Each one opens its own SQLAlchemy, asyncpg tile, and Procrastinate pool — check the connection budget below before raising it.
UVICORN_MAX_REQUESTS10000NoRecycle each API worker after this many requests (graceful exit + supervisor respawn), so slow memory growth can’t ride a worker into the container’s limit. Only applies with UVICORN_WORKERS ≥ 2 — with a single worker there is no supervisor to respawn it, so the prod stack omits the limit entirely rather than exiting the whole process.
UVICORN_TIMEOUT_KEEP_ALIVE5NoKeep-alive timeout in seconds; matches the uvicorn upstream default. Tune up for clients with chatty long-poll patterns.
UVICORN_TIMEOUT_GRACEFUL_SHUTDOWN30NoMax seconds uvicorn waits for in-flight requests to finish on shutdown before SIGKILL. The default covers slow GDAL ingest finalization.
VariableDefaultRequiredDescription
REDIS_URLNoneNoRedis/Valkey connection URL for cross-instance caching, and, since 1.19.0, the shared store rate limits are counted in. Leave unset for in-memory caching and per-worker rate-limit counting (single-instance default); with more than one API worker, an unset value means every configured limit is enforced once per worker. Rate limits use the store only for a redis:// or rediss:// URL; any other scheme logs rate_limit_storage_scheme_unsupported and counting stays per worker. Example: redis://valkey:6379/0.

These variables configure the default-on backup service. Off-site upload stays disabled until BACKUP_S3_ENABLED=true.

VariableDefaultRequiredDescription
BACKUP_SCHEDULE0 2 * * *NoCron expression for automated database backups. Default: daily at 2:00 AM UTC.
BACKUP_RETENTION_DAILY7NoNumber of daily backups to retain locally. Must be an integer of at least 1; the container refuses to start otherwise.
BACKUP_RETENTION_WEEKLY4NoNumber of weekly (Sunday) backups to retain locally. Must be an integer of at least 1; the container refuses to start otherwise.
BACKUP_S3_ENABLEDfalseNoEnable off-site backup upload to S3-compatible storage. Uses S3_* credentials.
BACKUP_MAX_AGE_MINUTES1560NoFreshness threshold for the backup container’s healthcheck, in minutes. The service reports unhealthy when the last fully successful backup cycle is older than this. Raise it to about 1.5x the interval for less-frequent BACKUP_SCHEDULE values.
BACKUP_MEM_LIMIT512mNoMemory cap for the backup container (the nightly pg_dump + tar peaks around 300 MB). Honoured by the release stack (docker-compose.prod.yml) only; the source-build stack sets no memory cap on the backup container.

Optional email (SMTP) and webhook alerts for signup, ingest, and health events. Everything defaults to off, so existing deployments are unaffected until NOTIFICATIONS_ENABLED=true and at least one channel is configured. See Backups & Restore and Infrastructure & Monitoring for the operational context.

VariableDefaultRequiredDescription
NOTIFICATIONS_ENABLEDfalseNoMaster toggle. When false, all notification sends are a no-op regardless of channel config.
SMTP_HOSTNoneNoSMTP server hostname. Configure together with SMTP_USERNAME, SMTP_PASSWORD, and SMTP_FROM_ADDRESS to enable the email channel.
SMTP_PORT587NoSMTP server port.
SMTP_USERNAME / SMTP_PASSWORDNoneNoSMTP credentials (secret; never rendered in logs).
SMTP_FROM_ADDRESSNoneNoFrom address for outbound email.
SMTP_USE_TLStrueNoUse STARTTLS for the SMTP connection.
NOTIFICATION_WEBHOOK_URLNoneNoIncoming-webhook endpoint (Slack, Teams, or custom) that receives JSON notifications.
NOTIFICATION_WEBHOOK_SECRETNoneNoOptional HMAC signing secret for webhook payloads.
NOTIFICATION_ADMIN_EMAILNoneNoRecipient for event alerts. Falls back to SMTP_FROM_ADDRESS when unset.
NOTIFY_ON_SIGNUPfalseNoSend an alert when a new account registers.
NOTIFY_ON_INGEST_COMPLETEfalseNoSend an alert when an ingest finishes successfully.
NOTIFY_ON_INGEST_FAILEDfalseNoSend an alert when an ingest fails.
NOTIFY_ON_HEALTH_ALERTfalseNoSend an alert when the health check reports a degraded status (cooldown-deduplicated).

These are environment-only settings (not stored in the admin settings database) because the channel credentials are secrets.

VariableDefaultRequiredDescription
WORKER_CONCURRENCY1NoNumber of jobs the background worker processes concurrently.
WORKER_QUEUESpriority,ingest,raster,download,ingest-auth-v2NoComma-separated list of job queues the worker consumes, in priority order. Set it only to move work between workers, and start from the full default when you do. Dropping download leaves File URL imports enqueued with no consumer. ingest-auth-v2 is a drain queue with no producer in this release: it exists so a credentialed import queued by a 1.18.0 or 1.18.1 API still runs, and a worker that omits it logs worker_queue_missing_drain_queue at startup.
WORKER_SHUTDOWN_TIMEOUT30NoGraceful shutdown timeout for the background worker in seconds
VariableDefaultRequiredDescription
ENV_ONLY_CONFIGfalseNoWhen true, all admin-overridable settings are locked to their environment values. The PersistentConfig DB layer is bypassed for reads and returns 403 on writes. Use for hardened production deployments where operators want to prevent runtime configuration changes via the admin UI.
VariableDefaultRequiredDescription
ENVIRONMENT(unset)NoDeployment environment: development or production. When production, the API hides its docs (/api/docs and /api/redoc return 404) and sets the Secure flag on the OAuth session cookie. When unset, the posture falls back to LOG_JSON for backward compatibility. Set ENVIRONMENT=production on any public, TLS-terminated deployment.

Deployment knobs (running outside Docker Compose)

Section titled “Deployment knobs (running outside Docker Compose)”

These knobs matter when the frontend image fronts the API outside the bundled Compose stack (bare containers, Kubernetes, the community Helm chart). Under stock Docker Compose the defaults are correct and you can leave them unset.

VariableDefaultRequiredDescription
API_UPSTREAMhttp://api:8000NoWhere the frontend’s nginx proxies /api, raster tiles, and embeds. Set to the API Service’s fully qualified name on Kubernetes. Trailing slashes are stripped.
NGINX_RESOLVERfirst nameserver in /etc/resolv.conf, else 127.0.0.11NoDNS resolver nginx uses to resolve API_UPSTREAM. Bare IPv6 resolvers are bracketed automatically.
CLIENT_MAX_BODY_SIZE500mNoUpload ceiling enforced by the frontend nginx. Invalid values fail fast at boot. Keep this at or above UPLOAD_MAX_SIZE_MB.
TRUSTED_PROXY_CIDRS(empty)NoComma/space-separated CIDRs of proxies trusted to set X-Forwarded-For/-Proto (e.g. Cloudflare’s published ranges). Required for correct client IPs and anonymous raster rate limiting behind a load balancer or CDN. Empty treats the direct peer as the client.

For a non-AWS S3 backend, the API and worker derive AWS_S3_ENDPOINT, AWS_HTTPS, and AWS_VIRTUAL_HOSTING from the S3_* settings above automatically; you do not normally set them by hand.

GeoLens supports two AI subsystems: inference (chat, map generation, metadata drafts) and embeddings (semantic search). They can use different providers.

API keys are set exclusively via environment variables. Provider and model can also be overridden at runtime from the admin Settings > AI tab. Base URL cannot: once OPENAI_API_KEY is set, the API refuses any saved base URL that does not resolve to the environment-approved endpoint for that purpose — OPENAI_BASE_URL for inference, and EMBEDDING_BASE_URL (falling back to OPENAI_BASE_URL) for embeddings, with OpenAI’s own endpoint standing in when neither is set. A database write therefore cannot redirect the environment-provided credential. Leaving the admin field blank is still accepted and means “inherit the environment endpoint”.

VariableDefaultRequiredDescription
ANTHROPIC_API_KEYNoneNoAnthropic API key. When set, Anthropic is the default inference provider.
LLM_MODELclaude-sonnet-5NoDefault Anthropic model name (admin-overridable).
MAX_AI_TOKENS_PER_USER_PER_DAY0Admin UI onlyManaged on the admin AI tab — not read from .env. Per-user daily AI token budget; 0 = unlimited. The value shown is the built-in default.
OPENAI_API_KEYNoneNoOpenAI-compatible API key. Used for inference when Anthropic key is absent, and always used for embeddings.
OPENAI_MODELgpt-4oNoDefault OpenAI-compatible model name (admin-overridable).
OPENAI_BASE_URLNoneNoCustom endpoint for OpenAI-compatible providers (Ollama, Groq, Together). Leave unset for default OpenAI.

Embeddings always use the OpenAI-compatible API. Anthropic does not provide an embedding endpoint.

VariableDefaultRequiredDescription
EMBEDDING_MODELtext-embedding-3-smallNoEmbedding model name (admin-overridable).
EMBEDDING_DIMS1536NoExpected vector dimensions (admin-overridable, auto-detectable from admin UI).
EMBEDDING_BASE_URLNoneNoSeparate endpoint for embeddings. Falls back to OPENAI_BASE_URL if unset.

Anthropic inference + OpenAI embeddings (recommended):

Terminal window
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...

OpenAI for everything:

Terminal window
OPENAI_API_KEY=sk-...

Anthropic inference + Ollama embeddings:

Terminal window
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=ollama # any non-empty value
EMBEDDING_BASE_URL=http://ollama:11434/v1
EMBEDDING_MODEL=nomic-embed-text

Ollama for everything:

Terminal window
OPENAI_API_KEY=ollama # any non-empty value
OPENAI_BASE_URL=http://ollama:11434/v1
OPENAI_MODEL=llama3
EMBEDDING_MODEL=nomic-embed-text

These variables control which ports are exposed on the Docker host. They do not affect internal container communication.

VariableDefaultDescription
DB_PORT5432Host port for PostgreSQL. Set to 5434 in .env.example to avoid conflicts.
API_PORT8000Host port for the FastAPI backend. Set to 8001 in .env.example.
FRONTEND_PORT8080Host port for the frontend.

These are the in-container ports; they are not operator knobs. The release stack (docker-compose.prod.yml) and the source-build stack (docker-compose.yml) differ only for the frontend:

ServicePortProtocol
PostgreSQL (db)5432TCP
FastAPI (api)8000HTTP
Worker (worker)8001HTTP (health only)
Titiler (titiler)8000HTTP
Frontend (frontend), release stack8080HTTP (nginx)
Frontend (frontend), source-build stack5173HTTP (Vite dev server)
VolumePurposeMount Point
pgdataPostgreSQL data persistence/var/lib/postgresql on db (PostgreSQL 18+ takes a single mount here and keeps PGDATA at <major>/docker inside it; mounting the legacy /var/lib/postgresql/data path makes the entrypoint refuse to boot)
upload_stagingUploaded file staging area/app/staging on api, worker, and titiler
backup_dataNightly pg_dump output plus a tar of upload_staging/backups on backup
frontend_cachenginx raster cache (release stack only)/var/cache/nginx on frontend
minio_data / valkey_dataMinIO and Valkey data for the optional cloud-dev profile/data on minio / valkey
azurite_dataAzurite emulator data (source-build stack only)/data on azurite
Terminal window
# Database
POSTGRES_DB=geolens
POSTGRES_USER=geolens
POSTGRES_PASSWORD=secure-db-password
# Auth
JWT_SECRET_KEY=a1b2c3d4e5f6... # Use: openssl rand -hex 32
GEOLENS_ADMIN_USERNAME=admin
GEOLENS_ADMIN_PASSWORD=secure-admin-password
# AI (optional; omit to disable AI features)
ANTHROPIC_API_KEY=sk-ant-... # Inference (chat, map generation)
OPENAI_API_KEY=sk-... # Embeddings (semantic search)
# Ports (non-default to avoid conflicts)
DB_PORT=5434
API_PORT=8001
FRONTEND_PORT=8080