Skip to content

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

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:

  1. Open the Firebase Console → select the project for the env you're configuring.
  2. Click the gear icon (top-left) → Project settings → General tab.
  3. Project ID is near the top (e.g. cs-lewis-fueled or cs-lewis-app).
  4. 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