Business Apps

Manage API Keys Safely in Production

Move AI API keys out of .env and onto a server safely: fail-fast settings, per-environment keys, redacted logs, scheduled rotation, and a leak response plan.

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.

How an API key reaches the OpenAI client at startup A flow diagram: the secret store hands the key to the process environment, the settings module reads and validates it, and the application either starts serving traffic or exits immediately when the key is missing. Secret store set once, encrypted Process env vars OPENAI_API_KEY Settings object checked at import key present key missing Client starts app serves traffic Boot fails fast exit before serving
The settings module is the only place that touches the environment, so a missing key stops the process at boot instead of breaking one request an hour later.

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.

Choosing where an API key should be stored for each environment A decision tree branching from the question of which environment the code runs in into three answers: a git-ignored dotenv file on a laptop, encrypted repository secrets on a continuous integration runner, and the hosting platform's secret store on a production server. Which environment? where the key lives Your laptop one developer CI / CD runner GitHub Actions Production server live customer traffic .env file git-ignored, local Repo secrets encrypted by CI Platform secrets injected at runtime
One question decides the answer: a file is acceptable only on the single machine you own, and every shared runner or server gets a managed store with its own key.

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.

The first hour after an API key is leaked A timeline with four stages and their elapsed times: revoke the exposed key, issue a replacement, redeploy every service with the new value, and only then clean the key out of the git history. 0-5 min Revoke the key provider dashboard attacker loses access 5-15 min Issue a new key different value old key stays dead 15-40 min Redeploy services restart every worker service comes back same day Clean git history then force-push history is cosmetic
Revoking comes first because it is the only step that actually stops the spending; rewriting history is tidy-up, and doing it first wastes the minutes that matter.

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

EnvironmentWhere the key livesLoaded by
Your laptop.env, git-ignored and docker-ignoredpython-dotenv
CI / scheduled jobsRepository or environment secretsThe workflow runner
StagingPlatform secret store, staging-only keyThe platform, at process start
ProductionPlatform secret store, production keyThe 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 or OPEN_AI_API_KEY looks 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. Print settings.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 deleted load_dotenv() while still testing locally. Importing settings first guarantees the ordering.
  • Works locally, fails in the container — nine times out of ten .env is correctly excluded by .dockerignore but nothing replaced it at run time. Confirm with docker run --rm myapp:latest python -c "import os; print('OPENAI_API_KEY' in os.environ)", which prints True or False without 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 .env file 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.

Frequently asked questions

Can I just upload my .env file to the server?

You can, and plenty of small projects do, but it is the weakest option. A file on disk survives reboots, gets copied into backups and container images, and is readable by anyone who can open a shell on that machine. Real environment variables set by your hosting platform leave no file behind.

What is the difference between an environment variable and a .env file?

An environment variable is a name and value handed to your program by whatever started it. A .env file is just a convenient way to fake those variables on your laptop: the python-dotenv library reads the file and copies its contents into the environment. In production the platform sets them directly, so no file is needed.

Should staging and production share one API key?

No. Give each environment its own key. Separate keys mean a runaway loop in staging cannot drain the production budget, a leaked staging key can be revoked without any customer-facing downtime, and provider usage dashboards show you which environment is actually spending money.

I committed an API key to GitHub. What do I do first?

Revoke the key in the provider dashboard before anything else. Deleting the commit does not help, because the key is already public and automated scanners find exposed keys within minutes. Issue a replacement, deploy it, then clean up the git history afterwards as housekeeping rather than as the fix.

How often should I rotate API keys?

A quarterly rotation is a reasonable default for a small application, plus an immediate rotation any time a key might have been exposed. What matters more than the exact interval is that rotating is boring: if swapping a key takes one dashboard visit and one redeploy, you will actually do it.