Environment Variables
Available to: Platform Admin only
Every Django setting that comes from the environment is loaded in config/settings.py via python-decouple. Production deploys use DigitalOcean App Platform (spec: .do/app.yaml) with components web, worker, beat, frontend, and migrate.
Related guides: Email Delivery, Deployment.
Rules of thumb
- Shared secrets must match across
web, worker, and beat — especially SECRET_KEY and TENANT_ENCRYPTION_KEY.
- Declaring a key in app.yaml is not enough — for
type: SECRET entries you must set the value in the App Platform dashboard. An empty secret behaves like an empty string at runtime.
- Email sends on the worker — almost all email uses Celery
.delay(). Mailgun variables on web alone are insufficient; worker must have them.
- No Django SMTP — this app does not use
EMAIL_HOST / EMAIL_BACKEND. Transactional email goes through the Mailgun HTTP API (apps/notifications/email.py).
Which component needs what
| Category |
web |
worker |
beat |
frontend |
migrate |
| Core Django |
Yes |
Yes |
Yes |
— |
Yes |
| Database / Redis |
Yes |
Yes |
Yes |
— |
Yes |
| Auth0 JWT |
Yes |
Yes |
— |
BUILD |
— |
| Auth0 MGMT (invites) |
Yes |
— |
— |
— |
— |
| Mailgun (email) |
Yes* |
Yes |
— |
— |
— |
| Twilio (SMS) |
Yes |
Yes |
— |
— |
— |
| AWS / Spaces |
Yes |
Yes |
Yes |
— |
— |
| CORS |
Yes |
— |
— |
— |
— |
| Frontend build |
— |
— |
— |
BUILD |
— |
*Web queues email tasks; the worker executes them.
Email delivery — required variables
These four variables are required for email to work. If either MAILGUN_API_KEY or MAILGUN_DOMAIN is empty, sends are skipped and EmailLog records skipped_no_config. See Email Delivery.
| Variable |
Example |
Where to get it |
MAILGUN_API_KEY |
key-xxxxxxxx |
Mailgun dashboard → API Keys → Private API key |
MAILGUN_DOMAIN |
mg.hardhatflow.com |
Mailgun → Sending → verified domain (or sandbox domain for testing) |
DEFAULT_FROM_EMAIL |
Hardhat Flow <[email protected]> |
Must use a domain that matches MAILGUN_DOMAIN |
FRONTEND_URL |
https://hardhatflow.com |
Public URL of the frontend app (links in email bodies) |
Optional:
| Variable |
Purpose |
PLATFORM_ADMIN_NOTIFY_EMAILS |
Comma-separated fallback recipients for access-request admin alerts when no platform-admin users exist |
- Open hardhat-flow → worker → Environment Variables.
- Add
MAILGUN_API_KEY, MAILGUN_DOMAIN, DEFAULT_FROM_EMAIL, FRONTEND_URL with real values. Encrypt secrets.
- Repeat the same four variables on web (same values).
- Redeploy the app (env changes require a new deployment).
- Trigger a test (invite a user or submit a bid for approval).
- Check django-admin → Companies → Email logs — expect
sent, not skipped_no_config.
Mailgun sandbox
Sandbox domains only deliver to authorized recipients in the Mailgun dashboard. For production, verify a custom sending domain and update MAILGUN_DOMAIN and DEFAULT_FROM_EMAIL to match.
Variable catalog
Core Django
| Variable |
Default |
Components |
Description |
DJANGO_SETTINGS_MODULE |
— |
web, worker, beat, migrate |
Always config.settings |
SECRET_KEY |
insecure dev key |
web, worker, beat, migrate |
Django signing key; must match across components |
DEBUG |
False |
web |
Never True in production |
ALLOWED_HOSTS |
localhost,127.0.0.1 |
web |
Comma-separated API hostnames; code auto-adds .ondigitalocean.app when DEBUG=False |
DJANGO_LOG_LEVEL |
INFO |
web, worker, beat |
Python logging level |
DJANGO_ENV |
development |
web |
Sentry environment tag |
CSRF_TRUSTED_ORIGINS |
auto from ALLOWED_HOSTS |
web |
HTTPS origins for django-admin CSRF; optional override (comma-separated, include https://) |
COLLECT_DOCS_STATIC |
True |
Docker build only |
Set False during Docker collectstatic (see Dockerfile) |
Database
| Variable |
Default |
Components |
Description |
DATABASE_URL |
— |
web, worker, beat, migrate |
Managed MySQL; use ${db.DATABASE_URL} on App Platform |
DB_NAME |
hardhat_flow |
local dev |
Used when DATABASE_URL is unset |
DB_USER |
root |
local dev |
|
DB_PASSWORD |
— |
local dev |
|
DB_HOST |
localhost |
local dev |
|
DB_PORT |
3306 |
local dev |
|
Redis / Celery
| Variable |
Default |
Components |
Description |
REDIS_URL |
redis://localhost:6379/0 |
web, worker, beat |
Celery broker and cache; use ${redis.REDIS_URL} on App Platform |
Multi-tenancy
| Variable |
Default |
Components |
Description |
TENANT_ENCRYPTION_KEY |
— |
web, worker, beat, migrate |
Fernet key for per-tenant DB credentials. Generate once; rotating invalidates stored tenant credentials |
Generate:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
Auth0 — API authentication (JWT)
| Variable |
Default |
Components |
Description |
AUTH0_DOMAIN |
— |
web, worker |
Tenant domain without https:// (e.g. dev-xxx.us.auth0.com) |
AUTH0_API_AUDIENCE |
— |
web, worker |
API identifier URL |
AUTH0_JWKS_CACHE_TTL_SEC |
300 |
web |
JWKS cache TTL (seconds) |
AUTH0_JWKS_FETCH_TIMEOUT_SEC |
5.0 |
web |
JWKS fetch timeout (seconds) |
AUTH0_REQUIRE_EMAIL_VERIFIED |
True |
web |
Reject tokens without verified email claim |
Auth0 — user invites (Management API)
| Variable |
Default |
Components |
Description |
AUTH0_MGMT_CLIENT_ID |
— |
web |
M2M application client ID |
AUTH0_MGMT_CLIENT_SECRET |
— |
web |
M2M application secret |
AUTH0_SPA_CLIENT_ID |
— |
web |
SPA client ID for password-change ticket URLs in welcome emails |
AUTH0_DB_CONNECTION |
Username-Password-Authentication |
web |
Auth0 database connection name |
AUTH0_INVITE_ENABLED |
True |
web |
Set False to skip Auth0 provisioning (tests/local dev) |
Invite welcome email only sends after Auth0 provisioning succeeds. Failures appear as AUTH0_INVITE_FAILED in tenant ActivityLog, not in EmailLog.
Email (Mailgun)
| Variable |
Default |
Components |
Description |
MAILGUN_API_KEY |
"" |
worker, web |
Empty = all sends skipped (skipped_no_config) |
MAILGUN_DOMAIN |
"" |
worker, web |
Sending domain |
DEFAULT_FROM_EMAIL |
Hardhat Flow <[email protected]> |
worker, web |
From address |
FRONTEND_URL |
https://app.hardhatflow.com |
worker, web |
Public frontend URL for email links |
PLATFORM_ADMIN_NOTIFY_EMAILS |
"" |
web |
Optional comma-separated fallback for access-request alerts |
SMS (Twilio)
| Variable |
Default |
Components |
Description |
TWILIO_ACCOUNT_SID |
— |
web, worker |
Twilio account SID |
TWILIO_AUTH_TOKEN |
— |
web, worker |
Twilio auth token |
File storage (DigitalOcean Spaces)
| Variable |
Default |
Components |
Description |
USE_S3 |
False |
web, worker, beat |
Set True in production |
AWS_ACCESS_KEY_ID |
— |
web, worker, beat |
Spaces access key |
AWS_SECRET_ACCESS_KEY |
— |
web, worker, beat |
Spaces secret |
AWS_STORAGE_BUCKET_NAME |
— |
web, worker, beat |
Bucket name |
AWS_S3_ENDPOINT_URL |
— |
app-level + components |
e.g. https://nyc3.digitaloceanspaces.com |
AWS_S3_REGION_NAME |
us-east-1 / nyc3 |
web, worker, beat |
Region slug |
AWS_S3_CUSTOM_DOMAIN |
— |
web |
Optional CDN domain |
AWS_QUERYSTRING_EXPIRE |
3600 |
web |
Presigned URL TTL (seconds) |
Spaces endpoint override
Do not re-declare AWS_S3_ENDPOINT_URL as an empty encrypted secret on a component — it can override the app-level value with a blank string and break boto3.
CORS
| Variable |
Default |
Components |
Description |
CORS_ALLOWED_ORIGINS |
http://localhost:3000 |
web |
Comma-separated frontend origin(s) |
Observability
| Variable |
Default |
Components |
Description |
SENTRY_DSN |
— |
web, worker |
Sentry error tracking DSN |
Backups
| Variable |
Default |
Components |
Description |
BACKUP_RETENTION_DAYS |
30 |
web |
Legacy setting; retention uses slot pruning |
Frontend (build-time only)
These are baked into the static export at build time. They do not affect Django runtime.
| Variable |
Components |
Description |
NEXT_PUBLIC_API_URL |
frontend BUILD |
Django web public URL without a /api suffix |
NEXT_PUBLIC_AUTH0_DOMAIN |
frontend BUILD |
Same value as AUTH0_DOMAIN |
NEXT_PUBLIC_AUTH0_CLIENT_ID |
frontend BUILD |
Auth0 SPA application client ID |
NEXT_PUBLIC_AUTH0_AUDIENCE |
frontend BUILD |
Same value as AUTH0_API_AUDIENCE |
NODE_ENV |
frontend BUILD |
production |
NEXT_BUILD |
frontend BUILD |
Set 1 for static export builds |
Do not add Mailgun variables to the frontend component.
Local / CI only (not production runtime)
| Variable |
Purpose |
DB_* |
Local MySQL when DATABASE_URL is unset |
JIRA_* |
scripts/jira_tasks.py only |
E2E_*_EMAIL, E2E_*_PASSWORD |
Playwright E2E auth setup |
PLAYWRIGHT_BASE_URL |
E2E base URL (default http://localhost:3000) |
Common misconfigurations
| Symptom |
Likely cause |
EmailLog skipped_no_config |
MAILGUN_* missing on worker (or empty secret value in DO) |
| No EmailLog row after event |
Worker not running; notification disabled in tenant Settings; or invite never reached Mailgun step |
EmailLog sent but no inbox delivery |
Mailgun sandbox recipient not authorized; spam/suppression — check provider_message_id in Mailgun |
| Wrong links in emails |
FRONTEND_URL set to API host instead of frontend host |
| Mailgun API error |
DEFAULT_FROM_EMAIL domain does not match MAILGUN_DOMAIN |
Double /api in browser |
NEXT_PUBLIC_API_URL incorrectly includes /api suffix |
| django-admin CSRF 403 |
Missing CSRF_TRUSTED_ORIGINS / proxy SSL header (fixed in code; redeploy required) |
Verification
Worker console (App Platform → worker → Console):
from django.conf import settings
print("mailgun", bool(settings.MAILGUN_API_KEY), settings.MAILGUN_DOMAIN)
print("from", settings.DEFAULT_FROM_EMAIL)
print("frontend", settings.FRONTEND_URL)
Expected: mailgun True <your-domain>, non-empty from address and frontend URL.
After a test event: django-admin → Companies → Email logs — filter by recent created_at and check status.