{"id":290,"date":"2026-11-13T12:00:00","date_gmt":"2026-11-13T12:00:00","guid":{"rendered":"https:\/\/rezachegini.com\/?p=290"},"modified":"2026-11-13T20:41:11","modified_gmt":"2026-11-13T20:41:11","slug":"building-a-job-board-api-part-16-all-config-from-environment-variables","status":"publish","type":"post","link":"https:\/\/rezachegini.com\/?p=290","title":{"rendered":"Building a Job Board API, Part 16: All Config from Environment Variables"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">I&#8217;m sharing one of my projects here from the beginning, step by step. It&#8217;s a Job Board API built with Django, Django REST Framework, PostgreSQL and Docker Compose, with S3 and SQS (through LocalStack), Redis, and a background worker.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;m a junior engineer looking for my first role in DevOps, SRE or Cloud. That&#8217;s why I build projects like this and write about them in public. It shows how I think, and writing it down helps me really understand it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In Parts 13, 14 and 15 I added health checks, metrics and JSON logs. This part is different. I didn&#8217;t add a new tool. I audited the configuration I had built over many steps, and I hardened it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The goal: one image that runs anywhere. The same image should work on my laptop, in CI, on AWS and on Azure. Everything that changes between those places comes only from environment variables:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Required values have no default. If one is missing, the app refuses to start with ImproperlyConfigured: Missing required environment variable: X. And an empty value counts as missing.<\/li>\n\n\n\n<li>Optional values have safe defaults: DEBUG is off, and ALLOWED_HOSTS allows only local names.<\/li>\n\n\n\n<li>I proved it by running the image with zero mounts and only environment variables. Readiness, metrics, the API and the JSON logs all worked.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The audit also found one real bug and three security problems. I&#8217;ll go through each of them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What counts as config?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Before moving things into environment variables, I had to decide what belongs there. I followed the 12-factor rule: config is what varies between deploys.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">These come from the environment, because they change per deployment:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>DJANGO_SECRET_KEY, DJANGO_DEBUG, DJANGO_ALLOWED_HOSTS, LOG_LEVEL<\/li>\n\n\n\n<li>POSTGRES_* and REDIS_URL<\/li>\n\n\n\n<li>AWS_REGION, the bucket and queue names, and the endpoint URLs<\/li>\n\n\n\n<li>EMAIL_BACKEND and DEFAULT_FROM_EMAIL<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">These stay in code, because they&#8217;re behaviour and should be the same everywhere:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>INSTALLED_APPS, MIDDLEWARE, REST_FRAMEWORK<\/li>\n\n\n\n<li>JOB_LIST_CACHE_SECONDS = 60 and the 1-second Redis timeouts<\/li>\n\n\n\n<li>RESUME_URL_EXPIRE_SECONDS = 300, the JWT lifetimes, PAGE_SIZE<\/li>\n\n\n\n<li>AWS_S3_FILE_OVERWRITE and AWS_QUERYSTRING_AUTH<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Why not put everything in environment variables? Because then the app could behave differently in each environment, in ways nobody reviewed. If I want to change the cache time, that should be a code change that someone reviews, not a switch someone flips during a deploy.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The env() helper: required, optional, and &#8220;empty means missing&#8221;<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">All my settings go through one small function in config\/settings.py:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def env(name, default=None):\n    \"\"\"Read a setting from the environment. No default = required; empty counts as missing.\"\"\"\n    value = os.environ.get(name)\n    if value is None or value == \"\":\n        if default is None:\n            raise ImproperlyConfigured(f\"Missing required environment variable: {name}\")\n        return default\n    return value<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">default=None means &#8220;required&#8221;. When I write env(&#8220;SQS_QUEUE_NAME&#8221;) with no default, I&#8217;m saying: there&#8217;s no safe guess for this setting.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">ImproperlyConfigured is Django&#8217;s own exception for broken settings. It&#8217;s raised while the settings file is being imported, so it stops every entry point at startup: runserver, manage.py, the worker, and gunicorn later. The error message names the missing variable. This is called fail fast. It&#8217;s much better than starting and failing on the first request, or running with a wrong value.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The change: empty now counts as missing<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">My old version was os.environ.get(name, default), and it only raised an error if the result was None. So DJANGO_SECRET_KEY= (set, but empty) passed, and Django got an empty secret key.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Empty values happen more often than you&#8217;d think:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>a line in the template that nobody filled in;<\/li>\n\n\n\n<li>-e VAR= with nothing after the =;<\/li>\n\n\n\n<li>a key missing from a Kubernetes Secret.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Now &#8220;&#8221; is treated exactly like &#8220;not set&#8221;. I tested it: -e DJANGO_SECRET_KEY= and -e AWS_REGION= both stop the app with Missing required environment variable: &#8230;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Optional values still work with the pattern env(&#8220;X&#8221;, &#8220;&#8221;) or None. An empty value returns the default &#8220;&#8221;, and or None turns it into None.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Environment variables are always strings<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>def env_bool(name, default=False):\n    return env(name, str(default)).strip().lower() in (\"1\", \"true\", \"yes\", \"on\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is an easy trap. In Python, bool(&#8220;False&#8221;) is True, because any non-empty string is truthy. So a simple DEBUG = env(&#8220;DJANGO_DEBUG&#8221;) would turn DJANGO_DEBUG=False into debug on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">str(default) turns the Python default into &#8220;False&#8221; or &#8220;True&#8221;, so it goes through the same parsing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">.strip().lower() accepts True, true, TRUE and so on.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Anything not in the &#8220;yes&#8221; list is False. A typo like ture gives the safe answer. I tested it: False, false, 0 and no gave False; True, yes and 1 gave True.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>def env_list(name, default=\"\"):\n    return &#91;item.strip() for item in env(name, default).split(\",\") if item.strip()]<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This turns a comma-separated string into a list. It&#8217;s a list comprehension: for each piece, strip the spaces, and keep it only if it isn&#8217;t empty. So &#8220;a,,b&#8221; or a trailing comma doesn&#8217;t create empty items.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Before, I had a comprehension like this written only for ALLOWED_HOSTS. Now it&#8217;s reusable, for example for CSRF_TRUSTED_ORIGINS later.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I chose these small helpers instead of a library like django-environ or pydantic-settings. It&#8217;s about 20 lines, there&#8217;s no new dependency, and I understand every line.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The Django core settings<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>SECRET_KEY = env(\"DJANGO_SECRET_KEY\")\nDEBUG = env_bool(\"DJANGO_DEBUG\", False)\nALLOWED_HOSTS = env_list(\"DJANGO_ALLOWED_HOSTS\", \"localhost,127.0.0.1\")<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">SECRET_KEY is required<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The secret key signs sessions, password-reset tokens and my JWTs. Anyone who knows it can forge a login. So there&#8217;s no default at all.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I proved that it really signs the tokens: after I rotated the key, my old token got 401, and a fresh login got 200. A new key makes every old signed token invalid.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">DEBUG defaults to False<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">If a deployment forgets the variable, it gets the safe behaviour. DEBUG=True shows full stack traces, settings and SQL to anyone who triggers an error. In my tests, a simple 400 error produced a 68 KB debug page.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My local .env.example still has DJANGO_DEBUG=True, and that&#8217;s fine. It&#8217;s the local template. The code default is what protects real deployments.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">ALLOWED_HOSTS: the first security problem<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The old default was &#8220;localhost,0.0.0.0,*,127.0.0.1&#8221;. That * accepted any Host header, so the protection was completely off.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why does that matter? Django uses the Host header to build absolute URLs, like password-reset links and redirects. With *, an attacker can send Host: evil.example and get links that point to their site. This is called Host header injection.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Now the default is local names only. I tested it: Host: evil.example got 400, localhost got 200, and from my MacBook, 10.0.0.149 got 200.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Bind address vs host name<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This part confused me, and I asked about it. I test the API from a second laptop, my MacBook. So I said: 0.0.0.0 must be in ALLOWED_HOSTS too.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">But with 0.0.0.0 in the list, the MacBook still got 400. Here&#8217;s why:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">ALLOWED_HOSTS is checked against the Host header the client sends. That&#8217;s the address typed into the laptop&#8217;s browser or curl. For me, that&#8217;s 10.0.0.149, the LAN IP of my home lab machine, thor.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">0.0.0.0 is a bind address. It tells the server to listen on every network interface. You can see it in docker compose ps as 0.0.0.0:8006-&gt;8006. It&#8217;s what makes thor reachable from the laptop, but no remote client ever sends Host: 0.0.0.0.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So I added 10.0.0.149. The laptop got 200, and evil.example still got 400. Before this step, the laptop only worked because * accepted every host.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">0.0.0.0 stays in my local list only for opening http:\/\/0.0.0.0:8006 on thor itself. And if thor&#8217;s IP comes from DHCP and can change, the fix is a DHCP reservation, or using the hostname if the laptop can resolve thor.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The old secret key in a comment: the second security problem<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The top of settings.py still had the docstring and comments that startproject generates. One of those comments was the original key: # SECRET_KEY = &#8216;django-insecure-6nxn&#8230;&#8217;.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why does a comment matter?<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The key is in the public git history.<\/li>\n\n\n\n<li>Secret scanners, like Gitleaks in my next project&#8217;s CI, flag django-insecure- keys.<\/li>\n\n\n\n<li>It invites someone to uncomment it one day.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">I deleted the whole header. The file now starts with import os.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Did I need to rewrite the git history? No. That key was never used by the app. My settings have read DJANGO_SECRET_KEY from the environment since the very beginning of the project. I proved it without printing anything: grep -c &#8220;django-insecure-6nxn&#8221; .env returned 0. A leaked key only matters if it&#8217;s the key in use.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The AWS settings: the bug nobody noticed<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>AWS_REGION = env(\"AWS_REGION\")\nAWS_STORAGE_BUCKET_NAME = env(\"AWS_STORAGE_BUCKET_NAME\")\nAWS_S3_REGION_NAME = AWS_REGION<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This is the real bug the audit found. My .env set a variable called AWS_S3_REGION. But the code read AWS_S3_REGION_NAME. So the .env value was silently ignored, and S3 used the default from the code.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing failed, because both happened to be us-east-1. It would only have broken in another region, and probably at a bad time. This is the kind of bug you only find by reading the config line by line.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">One variable, two uses. S3 uses AWS_S3_REGION_NAME (the name django-storages reads), and SQS uses AWS_REGION. Both now come from the same AWS_REGION, so they can&#8217;t disagree anymore. I deleted AWS_S3_REGION from .env and .env.example.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">AWS_REGION is now required. It used to default to us-east-1. But a region is deployment config. A ca-central-1 deployment that forgot the variable would quietly talk to the wrong region.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">AWS_REGION moved above its first use. Python runs the settings file from top to bottom, so AWS_S3_REGION_NAME = AWS_REGION needs it defined first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The endpoint URLs are optional:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>AWS_S3_ENDPOINT_URL = env(\"AWS_S3_ENDPOINT_URL\", \"\") or None\nAWS_S3_PUBLIC_ENDPOINT_URL = env(\"AWS_S3_PUBLIC_ENDPOINT_URL\", \"\") or AWS_S3_ENDPOINT_URL\n...\nAWS_SQS_ENDPOINT_URL = env(\"AWS_SQS_ENDPOINT_URL\", \"\") or None\nSQS_QUEUE_NAME = env(\"SQS_QUEUE_NAME\")<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Locally they point at LocalStack. On real AWS they&#8217;re unset, and None tells boto3 to use the real AWS endpoints.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">AWS credentials don&#8217;t appear in settings.py at all. boto3 reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the environment by itself. On EKS, these won&#8217;t even exist: IRSA gives the pod an IAM role instead of keys. That comes in my next project.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The database host: one source of truth<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>\"HOST\": env(\"POSTGRES_HOST\"),<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">POSTGRES_HOST used to default to localhost. But inside a container, localhost is the container itself, never the database. A default that&#8217;s always wrong is worse than no default, so now it&#8217;s required.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There was a second problem. My .env said localhost, but Docker Compose overrode it with environment: &#8211; POSTGRES_HOST=db in both services. So anyone reading .env saw the wrong value. Now .env says db, and the overrides in Compose are deleted. I checked: DATABASES[&#8216;default&#8217;][&#8216;HOST&#8217;] is db, and grep -c &#8220;POSTGRES_HOST=db&#8221; docker-compose.yml returns 0.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The template is the documentation<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">.env.example now lists every variable the code reads, grouped, with comments for the ones that aren&#8217;t obvious:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># Django\nDJANGO_SECRET_KEY=change-me\nDJANGO_DEBUG=True\nDJANGO_ALLOWED_HOSTS=localhost,127.0.0.1\nLOG_LEVEL=INFO\n\n# PostgreSQL (host = the Compose service name)\nPOSTGRES_DB=jobboard\n...\nPOSTGRES_HOST=db\nPOSTGRES_PORT=5432\n\n# Redis\nREDIS_URL=redis:\/\/redis:6379\/0\n\n# AWS (LocalStack locally; on AWS, leave the endpoint URLs empty and use IAM roles instead of keys)\nAWS_REGION=us-east-1\n...\nSQS_QUEUE_NAME=job-board-status\n\n# Email\nEMAIL_BACKEND=django.core.mail.backends.console.EmailBackend\nDEFAULT_FROM_EMAIL=Job Board &lt;no-reply@jobboard.test&gt;<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">cp .env.example .env gives a working local stack. Only the secret key and the LocalStack token have to be filled in.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">There are no quotes anywhere, on purpose. Docker Compose strips quotes from env_file values, but docker run &#8211;env-file doesn&#8217;t. So AWS_REGION=&#8221;us-east-1&#8243; would arrive under docker run as the literal string &#8220;us-east-1&#8221;, quotes included. Unquoted values behave the same in both. I checked: grep -c &#8216;=&#8221;&#8216; .env returns 0.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Editing my real .env without printing it<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">My real .env holds real secrets. So I had one rule for this whole step: never cat .env. Every check printed names, counts or lengths, never values.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I edited it with sed:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>cp .env .env.bak\nsed -i '\/^AWS_S3_REGION=\/d' .env\nsed -i 's\/^AWS_REGION=.*\/AWS_REGION=us-east-1\/' .env\nsed -i 's\/^POSTGRES_HOST=.*\/POSTGRES_HOST=db\/' .env\nsed -i 's\/^DJANGO_ALLOWED_HOSTS=.*\/DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0,10.0.0.149\/' .env<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">cp .env .env.bak makes a backup first.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">sed -i edits the file in place.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">\/^X=\/d deletes the line that starts with X=.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">s\/^X=.*\/X=new\/ replaces the whole line.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The LocalStack token: the third security problem<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">LocalStack needs an auth token. Before this step, it got the token through Compose interpolation: ${LOCALSTACK_AUTH_TOKEN:?&#8230;} in docker-compose.yml, read from .env.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The problem: api and worker load all of .env with env_file: .env. So the token was inside the app containers too, even though neither of them uses it. I checked before the fix, and both said SET (bad).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Why does that matter? Least privilege: a secret should exist only in the process that needs it. Anything in a container can read its environment: a shell, a dependency, a debug page, or docker inspect.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Interpolation and env_file are two different things<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I had to understand this difference to fix it:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Interpolation is ${VAR} written inside docker-compose.yml. Compose resolves it from the .env file in the project folder, and only Compose sees it. The value is used to build the config.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">env_file: sets variables inside the container. The app process can read every one of them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The fix: a secret file only LocalStack loads<\/h3>\n\n\n\n<pre class=\"wp-block-code\"><code>  localstack:\n    image: localstack\/localstack:2026.10.4\n    ports:\n      - \"127.0.0.1:4566:4566\"\n    env_file: .env.localstack\n    environment:\n      - SERVICES=s3,sqs\n      - S3_SKIP_SIGNATURE_VALIDATION=0\n      - AWS_STORAGE_BUCKET_NAME=${AWS_STORAGE_BUCKET_NAME:?set AWS_STORAGE_BUCKET_NAME in .env}\n      - SQS_QUEUE_NAME=${SQS_QUEUE_NAME:?set SQS_QUEUE_NAME in .env}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">env_file: .env.localstack is loaded by LocalStack only. If the file is missing, Compose fails fast with env file &#8230; not found. That replaces the old <code>:?<\/code> message.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">${VAR:?message} is Compose&#8217;s &#8220;required&#8221; syntax. If VAR is unset or empty, docker compose refuses to start and prints the message. The bucket name now has it too, like the queue name, because the init scripts (create-bucket.sh and create-queues.sh) need both.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The bucket and queue names still use interpolation from .env. Only the token moved.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I also deleted environment: &#8211; POSTGRES_HOST=db from api and worker, so env_file: .env is now the only config source for the app containers.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I moved the token without ever showing it:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep \"^LOCALSTACK_AUTH_TOKEN=\" .env &gt; .env.localstack\nsed -i '\/^LOCALSTACK_AUTH_TOKEN=\/d' .env\nchmod 600 .env .env.localstack<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">grep &#8230; &gt; copies the one line into the new file. chmod 600 makes both files readable by my user only.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Then a template, so a fresh clone shows which file to create:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>printf 'LOCALSTACK_AUTH_TOKEN=change-me\\n' &gt; .env.localstack.example\necho '!.env.localstack.example' &gt;&gt; .gitignore<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">.gitignore already ignores .env.*, which covers .env.localstack. I proved it with git check-ignore. The ! line is an exception: the template is tracked. And .dockerignore keeps both secret files out of the image. Running ls -a \/app in the image shows only .env.example.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">After the fix: api: not set, worker: not set, localstack: set, and LocalStack was (healthy).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Here is where each value goes now. The app containers get the whole .env, which no longer has the token. LocalStack gets the bucket and queue names through interpolation, and the token from its own file.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1533\" height=\"629\" src=\"http:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-16-diagram.png\" alt=\"\" class=\"wp-image-289\" srcset=\"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-16-diagram.png 1533w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-16-diagram-300x123.png 300w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-16-diagram-1024x420.png 1024w, https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-16-diagram-768x315.png 768w\" sizes=\"auto, (max-width: 1533px) 100vw, 1533px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">How I tested it<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">No mocks. Two simple tricks did all the work. -e VAR= on one command breaks one setting without editing any file. And docker run with no &#8211;env-file breaks all of them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Part 1: the Compose stack<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The values are right. DEBUG, ALLOWED_HOSTS, both region settings and the database host printed True [&#8216;localhost&#8217;, &#8216;127.0.0.1&#8217;, &#8216;0.0.0.0&#8217;, &#8216;10.0.0.149&#8217;] us-east-1 us-east-1 db. One region variable, and the host comes from .env only.<\/li>\n\n\n\n<li>Host header protection is back. On thor: Host: localhost got 200, Host: evil.example got 400. From the MacBook: 10.0.0.149 got 200, evil.example got 400.<\/li>\n\n\n\n<li>Empty means missing. -e DJANGO_SECRET_KEY= and -e AWS_REGION= both gave Missing required environment variable: &#8230;.<\/li>\n\n\n\n<li>Real boolean parsing. DJANGO_DEBUG set to False, false, 0, no, True, yes and 1 gave False four times and True three times.<\/li>\n\n\n\n<li>The secret key is safe, without printing it. Only counts and length: the placeholder change-me appeared 0 times, the old leaked key 0 times, and the real key&#8217;s length was 69. After the cleanup, django-insecure appeared 0 times in the settings.<\/li>\n\n\n\n<li>The key really signs tokens. After rotating it, my old JWT got 401, and a new login got 200.<\/li>\n\n\n\n<li>The token is where it should be. api: not set, worker: not set, localstack: set, and LocalStack was (healthy).<\/li>\n\n\n\n<li>Nothing else broke. A resume download returned 200, which matters because the S3 region setting changed. Readiness said ready. Two status changes (interview -&gt; reviewing and back) were both emailed.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Part 2: the image alone, with no mount<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the real test. In Compose, my source folder is mounted into the container. So I ran the image by itself, the way it would run in a real deployment:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>The image has this step&#8217;s code. docker run &#8211;rm $IMG grep &#8230; found def env_list once and django-insecure zero times. That proves the image is updated, not just my folder.<\/li>\n\n\n\n<li>No secrets inside. ls -a \/app in the image shows .env.example and no other env file.<\/li>\n\n\n\n<li>Fail fast. docker run &#8211;rm $IMG python manage.py check with no environment at all stopped with Missing &#8230; DJANGO_SECRET_KEY.<\/li>\n\n\n\n<li>Empty means missing in the image too. The same command with &#8211;env-file .env -e AWS_REGION= stopped with Missing &#8230; AWS_REGION.<\/li>\n\n\n\n<li>It runs from env vars only. docker run -d &#8211;env-file .env on port 8016 was up after 4 seconds, with 0 mounts.<\/li>\n\n\n\n<li>Everything works. \/readyz said ready with 3 checks ok. \/metrics returned 200, \/api\/jobs\/ returned 200, and an evil host got 400.<\/li>\n\n\n\n<li>The earlier steps are in the image. The token was not set in this container, and the last log line was JSON without the request field from Part 15.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">These commands repeat the image test at any time:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker compose build api worker\nIMG=00-job-board-api-api\ndocker run --rm $IMG python manage.py check 2&gt;&amp;1 | tail -n 1                 # Missing required environment variable: DJANGO_SECRET_KEY\ndocker run -d --name jb-envonly --network 00-job-board-api_default --env-file .env -p 127.0.0.1:8016:8006 $IMG\ndocker inspect -f '{{len .Mounts}} mounts' jb-envonly                         # 0 mounts\ncurl -s http:\/\/localhost:8016\/readyz | python3 -m json.tool | grep '\"status\"' # ready + 3 x ok\ncurl -s -o \/dev\/null -w \"%{http_code}\\n\" http:\/\/localhost:8016\/metrics        # 200\ndocker rm -f jb-envonly<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">Problems I hit<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The audit found six problems, and I explained them above: the dead AWS_S3_REGION, ALLOWED_HOSTS = *, the old key in a comment, empty values being accepted, two sources for POSTGRES_HOST, and the token in the app containers. Here are the problems I hit while fixing them.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. The old key survived my first cleanup<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">My first attempt to delete the old header didn&#8217;t work. head -n 1 still showed &#8220;&#8221;&#8221;, and django-insecure still appeared 1 time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix was a small Python script that kept everything from the first import os line. After that, the file started with import os, and the count was 0.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. My key-generation command was unsafe<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I planned to generate the new secret key with Django&#8217;s get_random_secret_key() and put it into .env with sed. But that function can produce &amp; and $:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">In a sed replacement, &amp; means &#8220;the whole matched text&#8221;. So the key could be silently corrupted.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Compose also interpolates $ in env_file values.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix: secrets.token_urlsafe(50), which only uses A-Z, a-z, 0-9, _ and -. I checked the result with a character-class grep, again without printing the key.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. The MacBook got 400<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">I explained this one in the ALLOWED_HOSTS section. 0.0.0.0 was in the list, but the laptop sends Host: 10.0.0.149. Adding that exact address fixed it, and evil.example still got 400.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">4. My helpers were missing again<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">r gave Command &#8216;r&#8217; not found. Like in Part 15, r and waitapi had only been typed into earlier shells and were never saved in scripts\/dev-helper.sh.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This time I used a guarded append, so running it twice can&#8217;t create duplicates:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>grep -q \"^r()\" || cat &gt;&gt; scripts\/dev-helper.sh ...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">After that, type r waitapi m showed all 3 as functions.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">5. I tested a stale image<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This is the most important lesson of this part.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">My first image-only test looked fine, but the logs still had the request socket field. I removed that field in Part 15. So why was it back?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The image was last built in the first half of Part 15. Every change after that reached the Compose containers only through the bind mount (.:\/app). The mount puts my source folder on top of the image&#8217;s own \/app, so it hides whatever code the image really has. Everything worked in Compose, while the image itself was old.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix: docker compose build api worker. Then I proved what&#8217;s inside the image:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>docker run --rm $IMG grep -c \"def env_list\" config\/settings.py   # 1<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Then I ran the image-only tests again.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Lesson: a Compose stack that passes with a source mount says nothing about the image you would ship. Test the image with no mount.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why I chose this way<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Small env() helpers instead of a library. About 20 lines and no new dependency. django-environ and pydantic-settings have more features, but they&#8217;re one more thing to learn.<\/li>\n\n\n\n<li>Empty means missing. It catches blank template lines and missing Secret keys. Checking only for None would accept an empty secret key.<\/li>\n\n\n\n<li>AWS_REGION and POSTGRES_HOST are required. There&#8217;s no safe default for either. us-east-1 and localhost would be silently wrong in other deployments.<\/li>\n\n\n\n<li>Tuning numbers stay in code. They&#8217;re the same everywhere, and changes get reviewed.<\/li>\n\n\n\n<li>A separate .env.localstack for the token. Only LocalStack sees it.<\/li>\n\n\n\n<li>Unquoted values in .env. Same result in Compose and in docker run.<\/li>\n\n\n\n<li>10.0.0.149 in my local .env instead of *. The laptop works, and the protection stays on.<\/li>\n\n\n\n<li>No git history rewrite for the old key. It was never used, and the real key is different. Rewriting history and force-pushing would protect nothing.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Known gaps<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Production security settings like SECURE_PROXY_SSL_HEADER, CSRF_TRUSTED_ORIGINS, secure cookies and HSTS depend on HTTPS at the Ingress. That comes in my next project, and python manage.py check &#8211;deploy will list them then.<\/li>\n\n\n\n<li>Kubernetes probes and Prometheus send the pod IP as Host. ALLOWED_HOSTS must handle that in Kubernetes. I&#8217;ve mentioned this in Parts 13 and 14 too.<\/li>\n\n\n\n<li>Secrets on AWS. .env is for local use only. On AWS, I&#8217;ll use Kubernetes Secrets fed by AWS Secrets Manager (with External Secrets), and IRSA instead of access keys.<\/li>\n\n\n\n<li>DEBUG=True locally shows the 68 KB debug page. That&#8217;s fine for development, but it must never be on in a deployment.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">What I learned<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Config is what changes between deploys. Behaviour stays in code, where changes are reviewed.<\/li>\n\n\n\n<li>No default means required, and empty means missing. Fail fast at startup, with the variable&#8217;s name in the error.<\/li>\n\n\n\n<li>Defaults should be safe. DEBUG off, and ALLOWED_HOSTS local only.<\/li>\n\n\n\n<li>Environment variables are always strings. bool(&#8220;False&#8221;) is True.<\/li>\n\n\n\n<li>ALLOWED_HOSTS matches what the client types, not the address the server listens on.<\/li>\n\n\n\n<li>Secrets follow least privilege. One secret file per service that needs it.<\/li>\n\n\n\n<li>Compose interpolation and env_file are different. One builds the config; the other goes inside the container.<\/li>\n\n\n\n<li>Check secrets without printing them. Use names, counts and lengths, and chmod 600 the files.<\/li>\n\n\n\n<li>A new SECRET_KEY invalidates every signed token.<\/li>\n\n\n\n<li>A bind mount can hide a stale image. Test the image you would ship, with no mount.<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">What comes next<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">This step completes the &#8220;runs with only env vars&#8221; half of Phase 4&#8217;s goal. Together with Parts 13 and 14, the container now runs with only environment variables and exposes health and metrics.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The last step of Phase 4 builds the production image: a multi-stage Dockerfile, a non-root user, and gunicorn instead of runserver. Then I&#8217;ll repeat this same image-only test against it.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;m sharing one of my projects here from the beginning, step by step. It&#8217;s a Job Board API built with Django, Django&hellip;<\/p>\n","protected":false},"author":2,"featured_media":288,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_rezaops_part":"16","_rezaops_linkedin_url":"https:\/\/www.linkedin.com\/pulse\/building-job-board-api-part-16-all-config-from-reza-chegini-p0gcc\/","footnotes":""},"categories":[10,116],"tags":[209,8,111,35,191,210,118,25,112,38],"series":[117],"class_list":["post-290","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-django","tag-configuration","tag-devops","tag-django","tag-docker","tag-drf","tag-environment-variables","tag-job-board","tag-python","tag-rest-api","tag-security","series-job-board-api"],"jetpack_featured_media_url":"https:\/\/rezachegini.com\/wp-content\/uploads\/2026\/11\/job-board-api-part-16-cover.png","_links":{"self":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/290","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=290"}],"version-history":[{"count":3,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/290\/revisions"}],"predecessor-version":[{"id":293,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/posts\/290\/revisions\/293"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=\/wp\/v2\/media\/288"}],"wp:attachment":[{"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=290"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=290"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=290"},{"taxonomy":"series","embeddable":true,"href":"https:\/\/rezachegini.com\/index.php?rest_route=%2Fwp%2Fv2%2Fseries&post=290"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}