By the end of this guide your deployed AI application will read its API key from a real environment variable, refuse to start if that key is missing, keep separate keys for staging and production, and never print the key into a log file. You will also have a written plan for the hour after a key leaks. Budget about forty minutes, most of it spent in your hosting platform's settings screen rather than in Python.
This guide belongs to Deploying Python AI Apps, the section covering everything between "the script works on my machine" and "the service runs without me watching it".
The short version of the problem: a .env file is a development convenience, not a security control. On your laptop it is fine — the file sits in one folder, on one encrypted disk, and python-dotenv copies its contents into memory when your script starts. On a server the same file becomes a permanent, plaintext copy of a credential that can spend real money, sitting in a filesystem that gets snapshotted, imaged, backed up, and shelled into. The fix is not a cleverer file. It is to stop having a file at all.
Prerequisites
You need Python 3.10 or newer, a working Python virtual environment, and an application you have already deployed somewhere — a container host, a platform-as-a-service box, or a scheduled job. If you have not got that far, Turn a Python AI Script into an API with FastAPI gets you a deployable service first.
pip install "openai>=1.40" "python-dotenv>=1.0"
pip freeze > requirements.txt
On your laptop only, keep a .env file with two values. The second one is what tells your code it is running locally:
OPENAI_API_KEY=sk-your-local-development-key
APP_ENV=local
Add it to .gitignore before you write another line, because this is the single mistake that costs people money:
echo ".env" >> .gitignore
echo ".env" >> .dockerignore
The .dockerignore line matters just as much. Without it, a COPY . . instruction in a container build copies your .env straight into an image layer, where it stays forever even if you delete the file in a later step.
Step 1 — Load configuration once, and fail fast if it is missing
Scattering os.getenv("OPENAI_API_KEY") through your codebase means a missing key surfaces as a confusing error deep inside a request, hours after deployment, in front of a user. Instead, read every setting once at import time in a single module, validate it there, and stop the process immediately if anything required is absent. A service that refuses to boot is easy to notice; a service that boots and then fails one request in ten is not.
Create settings.py:
"""settings.py — read and validate every setting once, at import time."""
import os
import sys
from dataclasses import dataclass
if os.environ.get("APP_ENV", "local") == "local":
# Laptop only: copy .env into the environment. In production the platform
# has already set real variables, so no file is ever read.
from dotenv import load_dotenv
load_dotenv()
REQUIRED = ("OPENAI_API_KEY", "APP_ENV")
@dataclass(frozen=True)
class Settings:
openai_api_key: str
app_env: str
model: str
def load_settings() -> Settings:
missing = [name for name in REQUIRED if not os.environ.get(name)]
if missing:
sys.exit("Startup aborted. Missing environment variables: " + ", ".join(missing))
return Settings(
openai_api_key=os.environ["OPENAI_API_KEY"],
app_env=os.environ["APP_ENV"],
model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
)
settings = load_settings()
Two details earn their keep. frozen=True makes the object read-only, so no later code can quietly overwrite the key with a test value. And sys.exit() with a plain sentence gives whoever reads the deployment log an instruction rather than a traceback — far friendlier than a KeyError twelve frames down.
Every other module now imports the finished object instead of touching the environment:
"""main.py — every other module imports the finished settings object."""
from openai import OpenAI
from settings import settings
client = OpenAI(api_key=settings.openai_api_key)
response = client.chat.completions.create(
model=settings.model,
messages=[{"role": "user", "content": "Say hello in five words."}],
)
print(response.choices[0].message.content)
The whole journey the key takes is now four hops, and only the first one changes between your laptop and your server.
Step 2 — Put the key in the platform's secret store, one per environment
Every hosting platform has a place to store secrets: a settings panel, a CLI command, or an encrypted store bound to your service. Whatever it is called, it does the same job — it holds the value encrypted, injects it as an environment variable when your process starts, and hides it from build logs. Use it, and delete the .env file you copied to the server.
Then split your keys. Generate a distinct key in your provider dashboard for each environment and label it clearly: myapp-local, myapp-staging, myapp-prod. This costs you two minutes and buys three real things. A test loop that accidentally sends ten thousand requests burns staging budget, not production budget. When a key leaks you revoke exactly one of them, and only the environment that leaked goes dark. And your provider's usage dashboard finally answers "which environment spent this money" without guesswork. Pair this with a hard ceiling as described in Set a Monthly AI API Spending Limit.
For scheduled work, the store is your repository's secrets panel. A workflow that runs on a timer, as covered in Schedule Python AI Jobs with GitHub Actions, reads the secret at run time and never stores it in the repository:
name: nightly-summary
on:
schedule:
- cron: "0 6 * * *"
jobs:
run:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python job.py
env:
APP_ENV: production
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
For containers, pass the value in at run time instead of baking it into the image. The bare --env NAME form forwards a variable that already exists on the host, so the key never appears in your shell history or in the image:
export OPENAI_API_KEY="$(your-secret-tool read myapp-prod)"
docker run --rm --env APP_ENV=production --env OPENAI_API_KEY myapp:latest
Which store you reach for depends only on where the code is running.
Step 3 — Redact the key everywhere it could be printed
Most leaked keys are not stolen. They are printed. Someone adds a debug line while chasing a bug, ships it, and the key lands in a log aggregator that half the company can search. The defence is to make the key structurally hard to print, then give yourself a safe alternative for the times you genuinely need to identify which key is loaded.
Update settings.py so the key is excluded from the object's own representation, and add a redact helper that produces a fingerprint instead of a credential:
"""settings.py (excerpt) — the key is now hard to print by accident."""
from dataclasses import dataclass, field
def redact(secret: str, keep: int = 4) -> str:
"""Turn a secret into something safe to write to a log."""
if not secret:
return "<empty>"
if len(secret) <= keep:
return "*" * len(secret)
return f"{secret[:3]}...{secret[-keep:]} len={len(secret)}"
@dataclass(frozen=True)
class Settings:
openai_api_key: str = field(repr=False) # never shown by print(settings)
app_env: str
model: str
def fingerprint(self) -> str:
return redact(self.openai_api_key)
field(repr=False) means print(settings) shows Settings(app_env='production', model='gpt-4o-mini') and nothing else. The fingerprint gives you enough to answer "is the server running the key I think it is?" — the first three characters and the last four are plenty to compare against your dashboard — while being useless to anyone who finds it.
One trap survives that change. vars() and __dict__ read the raw attributes and ignore repr=False entirely, so the classic panic move of dumping the whole configuration inside an exception handler leaks everything:
import logging
logging.basicConfig(level=logging.INFO)
try:
client.chat.completions.create(model=settings.model, messages=[])
except Exception:
# Wrong: vars() bypasses repr=False and writes the raw key to the log.
logging.exception("Call failed. Config: %s", vars(settings))
# Right: name the environment and the key fingerprint, nothing more.
logging.exception("Call failed in %s using key %s", settings.app_env, settings.fingerprint())
Make the same rule apply to error reporting. If you send exceptions to a monitoring service, check that it is not configured to attach local variables or the process environment to each report — that is a common default, and it will happily upload your key. The related habits for useful, safe logging are covered in Log and Monitor AI API Calls in Production.
Step 4 — Rotate on a schedule, and know the drill for a leak
Rotation means replacing a working key with a fresh one and revoking the old one. It is worth doing on a calendar, roughly every three months, for the same reason you change a door lock after a house move: you rarely know for certain who still holds a copy. A key that has existed for two years has passed through old laptops, screen shares, and former contractors.
Make the age visible so rotation is not something you forget. Store the issue date alongside the key and have your deployment check it:
"""key_age.py — refuse to deploy with a key that is overdue for rotation."""
import os
from datetime import date
issued = date.fromisoformat(os.environ.get("OPENAI_KEY_ISSUED", "1970-01-01"))
age_days = (date.today() - issued).days
print(f"Key issued {issued} — {age_days} days old")
if age_days > 90:
raise SystemExit("Rotate this key: it is older than 90 days")
A planned rotation is a four-step loop with no downtime: create the new key in the provider dashboard, add it to the secret store, redeploy so every process picks it up, then revoke the old key. Keep both keys valid for the few minutes in between, and confirm the application is still answering before you revoke. Only revoke last, or you will take the service down for the length of a deploy.
An emergency is the same loop with the order reversed, and the reversal is the whole point. The moment you suspect a key is public — pushed to a repository, pasted into a support ticket, shown in a screen recording — assume it has already been read. Automated scanners crawl public code continuously, and an exposed key can be found and used long before you notice.
So: revoke, replace, redeploy, then clean up. To find out what was exposed and for how long, search the history for the file and for the variable name:
git log --all --oneline -- .env
git log --all --oneline -S "OPENAI_API_KEY"
To remove the file from every commit, use git-filter-repo. This rewrites history, so tell your collaborators before you run it — everyone will need a fresh clone:
pip install git-filter-repo
git filter-repo --invert-paths --path .env --force
git push --force --all
Finish by reading your provider's usage page for the exposed period. If you see requests you cannot account for, that is your answer about whether the key was used, and it is worth contacting the provider's support with the timestamps.
Where the key lives in each environment
| Environment | Where the key lives | Loaded by |
|---|---|---|
| Your laptop | .env, git-ignored and docker-ignored | python-dotenv |
| CI / scheduled jobs | Repository or environment secrets | The workflow runner |
| Staging | Platform secret store, staging-only key | The platform, at process start |
| Production | Platform secret store, production key | The platform, at process start |
The pattern behind the table: exactly one machine may keep a file, and it is the one you physically own. Everything else gets a managed store and its own key.
Troubleshooting
Startup aborted. Missing environment variables: OPENAI_API_KEY— your platform did not inject the variable. Check the name character by character in the secret store; a trailing space orOPEN_AI_API_KEYlooks right at a glance and is not. Some platforms also require a restart, not just a redeploy, before new secrets appear.openai.AuthenticationError: Error code: 401— the key arrived but the provider rejected it, usually because it was revoked, belongs to a different organisation, or has a stray newline from a copy-paste. Printsettings.fingerprint()and compare it with the dashboard. Fix the 401 Unauthorized Error in OpenAI Python walks through each cause.openai.OpenAIError: The api_key client option must be set— you built the client before the settings module ran, or you deletedload_dotenv()while still testing locally. Importingsettingsfirst guarantees the ordering.- Works locally, fails in the container — nine times out of ten
.envis correctly excluded by.dockerignorebut nothing replaced it at run time. Confirm withdocker run --rm myapp:latest python -c "import os; print('OPENAI_API_KEY' in os.environ)", which printsTrueorFalsewithout revealing the value. If tracing the failure is slow going, Read a Python Traceback in Five Minutes helps you find the real line.
When to use this vs. alternatives
- Platform secret store (this guide) — the right default for a single application on one host. No extra service to run, no extra cost, and it covers the whole risk of a plaintext file on disk. Choose it unless you have a specific reason not to.
- A dedicated secret manager — a separate service your app calls at boot to fetch credentials. It adds audit logs, automatic rotation, and fine-grained access rules, which start to matter once several services and several people share the same credentials. It also adds a dependency your application cannot start without, so adopt it when the audit trail is worth that trade.
- A
.envfile on the server — only defensible for a throwaway prototype nobody else can reach, and even then set the file permissions to owner-only. The moment real customers or real spending are involved, move to a store. The effort is a five-minute change, and it is the one item on this list that quietly fails.
Getting this right is unglamorous and permanent: once the settings module fails fast, the store holds the value, and the logs carry a fingerprint instead of a credential, you stop thinking about keys entirely. Add per-user throttling from Rate-Limit AI API Calls in a SaaS with Python and you have covered both halves of the "someone spent my budget" problem. Back to Deploying Python AI Apps.
Related guides
- Turn a Python AI Script into an API with FastAPI — build the deployable service this guide secures.
- Schedule Python AI Jobs with GitHub Actions — run timed jobs that read the key from repository secrets.
- Log and Monitor AI API Calls in Production — record what every call cost without recording the credential.
- Set a Monthly AI API Spending Limit — cap the damage a compromised key can do.
- Add User Authentication to a Python AI App — the other half of access control, for your users rather than your provider.