Environment variables¶
Local values live in .env (copied from .env.example). In AWS, non-secret values
are set on the ECS task definition and real secrets come from AWS Secrets Manager.
See Deployment. Config is read from the environment
(12-factor); nothing sensitive is committed.
| Variable | Default | Notes |
|---|---|---|
DJANGO_SETTINGS_MODULE |
config.settings.local |
config.settings.prod in AWS (incl. the dev env) |
DJANGO_SECRET_KEY |
insecure-dev-key… |
Real value from Secrets Manager in prod |
DJANGO_DEBUG |
True (dev) |
False everywhere else |
SITE_DOMAIN |
localhost |
Drives ALLOWED_HOSTS / CSRF trusted origins |
DJANGO_ADMIN_URL |
backstage/ |
Django admin route prefix (leading slash optional) |
WAGTAIL_ADMIN_URL |
studio/ |
Wagtail admin route prefix |
DATABASE_URL |
(unset → sqlite) | e.g. postgres://cslewis:cslewis@localhost:5432/cslewis |
REDIS_URL |
(unset → locmem) | e.g. redis://localhost:6379/0 |
FIREBASE_PROJECT_ID |
(empty) | Firebase project identifier for token validation (see below) |
FIREBASE_WEB_API_KEY |
(empty) | Firebase Web API Key for account deletion (see below) |
FIREBASE_AUTH_DOMAIN |
<project id>.firebaseapp.com |
Web SDK authDomain for the deletion page's email-link sign-in |
DEV_AUTH_ENABLED |
same as DEBUG |
Enables POST /api/auth/dev-login/. Must be off in prod. |
API surface¶
| Variable | Default | Notes |
|---|---|---|
API_KEYS |
(empty) | Comma-separated list accepted in X-API-Key. Empty allows every request when DEBUG=True and denies every request otherwise |
API_VERSION |
1.0 |
Returned by GET /api/config/ and used as the OpenAPI schema version |
API_PUBLIC_CACHE_SECONDS |
30 |
max-age on the public, cacheable API responses |
API_PRIVATE_CACHE_SECONDS |
same as API_PUBLIC_CACHE_SECONDS |
max-age on licensed-content responses, which are private (app cache only) |
CONTENT_TOKEN_ENFORCED |
False |
Refuse licensed-content requests without a content-access token (CSL-257). A sent token is always checked. True in the dev buildspec. False in the prod buildspec until every app build that reaches prod sends tokens |
CONTENT_TOKEN_TTL_SECONDS |
900 |
Content-access token lifetime (CSL-256) |
CONTENT_TOKEN_SECRET |
(empty) | Optional dedicated signing key for content tokens. Empty signs with DJANGO_SECRET_KEY under a dedicated salt. Not wired in IaC; add it to the app secret to rotate tokens apart from DJANGO_SECRET_KEY |
CONTENT_TOKEN_SECRET_FALLBACKS |
(empty) | Comma-separated retired signing keys still accepted while their tokens expire |
CONTENT_TOKEN_MINT_RATE |
60/hour |
Token mints per network (CSL-258). DRF rate string, set in both buildspecs |
LICENSED_CONTENT_BURST_RATE |
60/min |
Licensed reads per token session, short window |
LICENSED_CONTENT_SUSTAINED_RATE |
600/hour |
Licensed reads per token session, long window |
LICENSED_CONTENT_NETWORK_RATE |
3000/hour |
Licensed reads per network across all sessions; caps a client that mints a token per request |
CONTENT_ABUSE_STRIKES |
3 |
Limit hits within the window before a session is refused outright |
CONTENT_ABUSE_WINDOW_SECONDS |
3600 |
Window for counting those hits |
CONTENT_ABUSE_BLOCK_SECONDS |
900 |
How long a blocked session is refused |
CLIENT_IP_META_KEY |
HTTP_CF_CONNECTING_IP in prod settings, empty otherwise |
Request META key with the real client address, for the network limits. Trusted because the ALB only accepts Cloudflare. Empty uses REMOTE_ADDR |
API_DOCS_ENABLED |
same as DEBUG |
Serves /api/schema/, /api/docs/, /api/redoc/ and the browsable API |
Deep links¶
These feed the two association files the mobile OSes fetch:
/.well-known/apple-app-site-association and /.well-known/assetlinks.json. All four are lists.
Leave them empty and the file is served with no entries, which is what local development wants.
| Variable | Default | Notes |
|---|---|---|
IOS_APP_IDS |
(empty) | Full <TeamID>.<BundleID> app IDs for the AASA file |
APPLINK_PATHS |
/finishSignIn,/finishSignIn/* |
Paths the iOS app claims |
ANDROID_PACKAGES |
(empty) | Package names for assetlinks.json |
ANDROID_SHA256_FINGERPRINTS |
(empty) | Signing-certificate fingerprints. Empty emits no statements |
Firebase settings¶
FIREBASE_PROJECT_ID and FIREBASE_WEB_API_KEY are public identifiers (they
ship inside every copy of the mobile app), not secrets. They're only needed
locally if you want to validate real Firebase ID tokens; without them, use the
dev-login escape hatch (DEV_AUTH_ENABLED defaults on when DEBUG=True).
Dev and prod are separate Firebase projects (CSL-462): dev uses cs-lewis-fueled, prod uses cs-lewis-app (the client's own GCP billing account).
To find the values:
- Open the Firebase Console → select the project for the env you're configuring.
- Click the gear icon (top-left) → Project settings → General tab.
- Project ID is near the top (e.g.
cs-lewis-fueledorcs-lewis-app). - Web API Key is just below it (starts with
AIzaSy…).
Alternatively, download google-services.json from the same page and read
project_info.project_id and client[0].api_key[0].current_key.
The account-deletion page (/delete-account/) renders all three values into its
HTML for the Firebase web SDK. Leave FIREBASE_AUTH_DOMAIN unset unless the
project's auth domain is not <project id>.firebaseapp.com. Without
FIREBASE_WEB_API_KEY or FIREBASE_PROJECT_ID the page shows no web sign-in.
Audio Studio settings¶
The in-CMS Audio Studio produces narration through ElevenLabs, with an AWS Bedrock
pass that drafts performance cues. Every value degrades safely: with no
ELEVENLABS_API_KEY the studio disables Generate and shows a banner; with no
BEDROCK_MODEL_ID scripts start as the plain text.
| Variable | Default | Notes |
|---|---|---|
ELEVENLABS_API_KEY |
(empty) | Secrets Manager in AWS. Needs text_to_speech + user_read scopes |
ELEVENLABS_VOICE_ID |
aQzi7JE7SdhWNPlXsMcH |
Last-resort voice, used only when AUDIO_VOICES is empty. The studio picks per section kind from AUDIO_VOICE_DEFAULTS (Lewis for quotes and letters, the narrator voice for editorial); a kind with no default falls back to the first entry in AUDIO_VOICES |
ELEVENLABS_MODEL_ID |
eleven_v3 |
Pinned per row — a model change is a deliberate re-record |
ELEVENLABS_OUTPUT_FORMAT |
mp3_44100_128 |
128 kbps is enough for speech on mobile |
ELEVENLABS_VOICE_SETTINGS |
stability 0.5, similarity 0.75… | JSON; the Voice Studio baseline |
ELEVENLABS_MAX_CHARS |
5000 |
Per-request character cap, ElevenLabs v3's own documented limit. Generation runs in a background thread (ADR-0014), not the editor's request, so this is no longer bounded by Cloudflare's 100 s origin timeout |
BEDROCK_MODEL_ID |
(empty) | Cross-region inference profile id (us.…) |
BEDROCK_REGION |
us-east-2 |
|
AUDIO_STUDIO_AUTO_DRAFT |
True |
Draft scripts (Bedrock) on landing a studio screen. Audio generation is never automatic — the editor clicks Generate |
LISTEN_SAMPLE_AUDIO |
same as DEBUG |
Sample tracks on the API where no real narration exists. Off in prod. |
JOURNEY_GATE_ENABLED |
True |
The journey paywall. On by default; set it False to suspend gating in an environment. Off suspends only the outcome — a reader's free journey is still claimed on their first Start, so turning it back on finds everyone already holding the journey they began with. |
AUDIOBOOK_GATE_ENABLED |
False |
The audiobooks paywall (CSL-479). Off by default in every environment — nothing reads this setting yet; CSL-348 is the first consumer, once the audiobook gate is built. |
SUBSCRIPTION_NOTICES_ENABLED |
False |
Send the renewal reminder and cooling-off emails (CSL-383, CSL-425). Off still records and renders each notice in the Django admin; nothing is sent. True in the dev buildspec (console mail backend), False in prod until the ship decision. |
SUBSCRIPTION_NOTICES_INCLUDE_SANDBOX |
False |
Give sandbox (test) subscriptions notices too. True in dev for QA, never in prod. |
Long-form narration¶
A body over ELEVENLABS_MAX_CHARS is split, generated chunk by chunk, and stitched into one mp3.
| Variable | Default | Notes |
|---|---|---|
AUDIO_LONGFORM_ENABLED |
True |
Off refuses an over-cap body instead of chunking it |
ELEVENLABS_LONGFORM_MODEL_ID |
eleven_multilingual_v2 |
Model used for chunked takes. Empty falls back to ELEVENLABS_MODEL_ID |
AUDIO_LONGFORM_CHUNK_SOFT_LIMIT |
4000 |
Target size per chunk, clamped below ELEVENLABS_MAX_CHARS |
AUDIO_LONGFORM_MAX_CHARS |
40000 |
A longer section is refused before any credits are spent |
AUDIO_LONGFORM_MAX_DURATION_SECONDS |
7200 |
A stitched take longer than this is refused |
Payments / RevenueCat settings¶
RevenueCat is the source of truth for subscription state; the backend mirrors it. The API key and the webhook token come from Secrets Manager in AWS. The HMAC secret is optional. See Subscriptions for how they are used.
| Variable | Default | Notes |
|---|---|---|
REVENUECAT_API_KEY |
(empty) | RevenueCat v2 secret key, sent as Bearer, for the customer current-state sync. Empty disables the sync. |
REVENUECAT_PROJECT_ID |
(empty) | The RevenueCat project the v2 endpoints are scoped to (e.g. proj7d44db36). Empty disables the sync. |
REVENUECAT_WEBHOOK_TOKEN |
(empty) | The Authorization value set on the RevenueCat webhook. Empty rejects every webhook (fail-closed). |
REVENUECAT_WEBHOOK_HMAC_SECRET |
(empty) | Optional. Set only if HMAC signing is enabled on the webhook; then the signature is verified too. |
Email¶
Nothing in the app sends mail yet. The wiring is in place for the planned renewal reminders
(CSL-383): production uses SES, and anywhere else prints the message to the console. SUPPORT_EMAIL
is different. It is not a sending address, it is the address the account-deletion page shows readers
as a mailto: link.
| Variable | Default | Notes |
|---|---|---|
DEFAULT_FROM_EMAIL |
C.S. Lewis <no-reply@mail.cslewis.com> |
From address on outgoing mail |
SERVER_EMAIL |
same as DEFAULT_FROM_EMAIL |
From address on Django's own error mail |
SUPPORT_EMAIL |
appsupport@narnia.com |
Address the account-deletion page links to. Nothing is sent to it by the backend |
DJANGO_EMAIL_BACKEND |
SES in prod, console elsewhere (prod settings) | Override to force one backend |
AWS_SES_REGION_NAME |
same as AWS_DEFAULT_REGION (prod settings) |
SES API region |
AWS_SES_REGION_ENDPOINT |
email.<region>.amazonaws.com (prod settings) |
SES endpoint host |
AWS_SES_CONFIGURATION_SET |
(empty) (prod settings) | Set it to track bounces and complaints |
Storage (prod settings)¶
| Variable | Default | Notes |
|---|---|---|
AWS_S3_UPLOADS_BUCKET |
(required) | Bucket holding images, documents, and narration |
AWS_S3_CUSTOM_DOMAIN |
(empty) | CloudFront domain. Set it and media URLs go https with no querystring auth |
AWS_DEFAULT_REGION |
us-east-2 |
Region for S3, and the default for SES |
Hosting, security, and logging¶
| Variable | Default | Notes |
|---|---|---|
DJANGO_ALLOWED_HOSTS |
SITE_DOMAIN, localhost, 127.0.0.1 |
Prod defaults to SITE_DOMAIN alone. Local and test settings ignore this variable |
DJANGO_SECURE_SSL_REDIRECT |
True (prod settings) |
Health-check paths stay exempt |
DJANGO_SECURE_HSTS_SECONDS |
31536000 (prod settings) |
HSTS max-age |
DJANGO_LOG_LEVEL |
INFO |
Level on the root logger |
WAGTAILADMIN_BASE_URL |
https://<SITE_DOMAIN> |
Base URL Wagtail uses when it needs an absolute admin link |
HOME_REDIRECT_URL |
https://cslewis.com |
Where / redirects. The backend serves no landing page |
CMS environment tab¶
A coloured tab across the top of every Wagtail admin page, sign-in included. Production leaves
ENVIRONMENT_NAME empty so the tab does not render. See the
editor guide.
| Variable | Default | Notes |
|---|---|---|
ENVIRONMENT_NAME |
Local environment locally, <env> environment on deployed non-prod |
Empty hides the tab |
ENVIRONMENT_COLOR |
#b91c1c locally, #b45309 otherwise |
Tab background |
ENVIRONMENT_TEXT_COLOR |
#ffffff |
Tab text |
Observability (prod settings)¶
| Variable | Default | Notes |
|---|---|---|
SENTRY_DSN |
(empty) | Empty skips Sentry entirely |
SENTRY_ENVIRONMENT |
prod |
Tags Sentry events. Also picks the email backend and the CMS tab label |
SENTRY_TRACES_SAMPLE_RATE |
0.0 |
Performance-trace sampling rate |