Environment variables in Python and Bash: configure like a pro
Intro – what, why, and who this is for
Configuration lives outside of source code. By storing values such as API keys, database URLs, or feature flags in environment variables you:
| Benefit | Why it matters |
|---|---|
| Portability | Same code runs on dev, CI, and production without changes. |
| Security | Secrets never get committed to Git. |
| Flexibility | Switch behaviour with a single line change. |
If you’re a beginner who has been hard‑coding values, or an intermediate developer looking to tidy up a growing codebase, this guide will show you how to:
- Create and load a
.envfile in Bash. - Pull those variables into Python with sensible defaults.
- Keep secrets out of version control and logs.
All commands and scripts are ready to copy‑paste and run.
Part 1 – .env files and Bash basics
1.1 What a .env file looks like
A .env file is a plain‑text list of KEY=VALUE pairs, one per line. Blank lines and lines that start with # are ignored.
# .env – example for a web app
APP_ENV=development
DEBUG=True
DB_HOST=localhost
DB_PORT=5432
DB_USER=app_user
DB_PASSWORD=super_secret_password
API_KEY=abcdef123456
| Rule | Example |
|---|---|
No spaces around = |
KEY=value (good) vs KEY = value (bad) |
Quote only when you need a literal # or leading/trailing space |
PATH="/usr/local/bin" |
| Do not commit this file | Add .env to .gitignore |
1.2 Loading a .env file in Bash
The simplest way is to source it:
# load-env.sh
set -o allexport # automatically export every variable
source .env
set +o allexport
Run it once per session:
source load-env.sh
echo $APP_ENV # → development
If you prefer a one‑liner, use export $(cat .env | xargs) – but be aware that it fails on values containing spaces or special characters. The allexport method is safer.
1.3 Making the variables permanent for a project
Create a wrapper script that both loads the env file and runs your command:
#!/usr/bin/env bash
# run.sh – usage: ./run.sh python app.py
set -o allexport
source "$(dirname "$0")/.env"
set +o allexport
exec "$@"
Now ./run.sh python app.py runs the Python script with the environment already populated.
Part 2 – Accessing env vars in Python
2.1 The os.getenv function
import os
debug = os.getenv("DEBUG", "False") # default if not set
port = int(os.getenv("DB_PORT", "5432"))
os.getenv(key, default) returns None when the variable is missing and no default is supplied. Always provide a default for non‑critical settings.
2.2 Converting types safely
Environment variables are strings. Convert them explicitly:
def str_to_bool(value: str) -> bool:
return value.lower() in ("true", "1", "yes")
DEBUG = str_to_bool(os.getenv("DEBUG", "false"))
2.3 Centralising configuration
A tiny config.py keeps all look‑ups in one place:
# config.py
import os
def _get(key: str, default: str = None) -> str:
"""Raise a clear error if a required key is missing."""
value = os.getenv(key, default)
if value is None:
raise EnvironmentError(f"Missing required env var: {key}")
return value
APP_ENV = _get("APP_ENV", "production")
DEBUG = _get("DEBUG", "False").lower() == "true"
DB_HOST = _get("DB_HOST", "localhost")
DB_PORT = int(_get("DB_PORT", "5432"))
DB_USER = _get("DB_USER")
DB_PASSWORD = _get("DB_PASSWORD")
API_KEY = _get("API_KEY")
Now any module can simply from config import DB_HOST, DB_PORT.
2.4 Loading a .env file automatically (optional)
For local development you may want Python to read .env without a wrapper script. The third‑party package python‑dotenv does that in one line:
pip install python-dotenv
# config.py (alternative)
from pathlib import Path
from dotenv import load_dotenv
import os
load_dotenv(Path(__file__).parent.parent / ".env") # locate .env at project root
# … then use os.getenv as before
Only add this in development; production environments should provide real OS variables.
Part 3 – Keeping secrets safe
| Practice | How to do it |
|---|---|
Never commit .env |
Add .env and any *.env.* files to .gitignore. |
| Use a secret manager | In cloud (AWS Secrets Manager, GCP Secret Manager) inject variables at runtime. |
| Restrict file permissions | chmod 600 .env – readable only by the owner. |
| Avoid printing | Never print(os.getenv("DB_PASSWORD")) in logs. |
| Rotate keys | Change the secret, update the .env, and redeploy without code changes. |
3.1 Example: Docker + .env
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Run the container with the host’s .env:
docker build -t myapp .
docker run --env-file .env myapp
Docker passes each line as an environment variable, keeping the secret out of the image layers.
3.2 Example: GitHub Actions secret injection
# .github/workflows/ci.yml
name: CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
env:
DB_PASSWORD: $
steps:
- uses: actions/checkout@v3
- name: Install
run: pip install -r requirements.txt
- name: Test
run: pytest
GitHub stores DB_PASSWORD encrypted; the workflow receives it as an env var without ever writing it to disk.
Hands‑on example – a tiny Flask app that reads a DB URL from the environment
File layout
myproject/
├─ .env
├─ app.py
├─ config.py
├─ requirements.txt
└─ run.sh
1️⃣ .env
# .env
APP_ENV=development
DEBUG=True
DB_HOST=localhost
DB_PORT=5432
DB_USER=flask_user
DB_PASSWORD=flask_secret
2️⃣ requirements.txt
Flask==3.0.0
python-dotenv==1.0.1 # optional, only for dev
3️⃣ config.py
# config.py
import os
from pathlib import Path
from dotenv import load_dotenv
# Load .env only when it exists (development)
dotenv_path = Path(__file__).parent / ".env"
if dotenv_path.is_file():
load_dotenv(dotenv_path)
def _get(key: str, default: str = None) -> str:
value = os.getenv(key, default)
if value is None:
raise EnvironmentError(f"Missing required env var: {key}")
return value
APP_ENV = _get("APP_ENV", "production")
DEBUG = _get("DEBUG", "False").lower() == "true"
DB_HOST = _get("DB_HOST", "localhost")
DB_PORT = int(_get("DB_PORT", "5432"))
DB_USER = _get("DB_USER")
DB_PASSWORD = _get("DB_PASSWORD")
4️⃣ app.py
# app.py
from flask import Flask, jsonify
from config import APP_ENV, DEBUG, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD
app = Flask(__name__)
app.config["ENV"] = APP_ENV
app.config["DEBUG"] = DEBUG
@app.route("/info")
def info():
return jsonify({
"environment": APP_ENV,
"debug": DEBUG,
"db": {
"host": DB_HOST,
"port": DB_PORT,
"user": DB_USER,
# Never expose the password!
}
})
if __name__ == "__main__":
# Use host/port from env or defaults
app.run(host="0.0.0.0", port=5000)
5️⃣ run.sh
#!/usr/bin/env bash
# run.sh – start the Flask app with env vars loaded
set -o allexport
source "$(dirname "$0")/.env"
set +o allexport
exec python app.py
Run it
chmod +x run.sh
./run.sh
# → Flask starts, listening on http://0.0.0.0:5000
curl http://localhost:5000/info | jq
You should see a JSON payload containing the environment, debug flag, and DB connection info (without the password). All values came from the .env file, but the same code works unchanged if a CI system injects real production variables.
Common mistakes / troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
KeyError: 'DB_PASSWORD' on start‑up |
Variable not set and no default supplied | Add the variable to .env or export it in the shell before running. |
python-dotenv loads an old .env |
.env was edited after the process started |
Restart the Python process (or use load_dotenv(override=True)). |
Bash prints command not found after source .env |
The .env file contains spaces around = or unquoted values with spaces |
Remove spaces (KEY=value) and quote values with spaces (KEY="my value"). |
| Docker image contains the secret in a layer | ENV VAR=value used in Dockerfile instead of --env-file at run time |
Pass secrets with docker run --env-file .env … or use Docker secrets. |
| CI logs show the password | echo $DB_PASSWORD or a step that prints the whole environment |
Remove echo statements; use masked secrets in CI settings. |
Try it yourself
- Create a new folder
demo/. - Copy the five files from the hands‑on example, but change
APP_ENVtostagingand set a differentDB_PASSWORD. - Run
./run.shand verify that/inforeturns the newstagingvalue while the password stays hidden.
If the endpoint shows the updated environment, you’ve mastered loading and reading env vars.
What’s next
- Docker Compose with multiple
.envfiles – learn per‑service configuration. - Using
dotenv-clifor one‑off commands – no wrapper script needed. - Secret rotation pipelines – automate key updates without downtime.
- Typed settings with
pydanticordynaconf– add validation on top of env vars.
Bookmark this guide for quick reference when you set up your next project.