Managing secrets in a Python app without a .env file
How-to
Most Python projects handle secrets with python-dotenv: a .env file in the
project root, load_dotenv() at the top of the entrypoint, and os.environ
reads everywhere else. The reads are fine. The file is the problem. It's
plaintext, it's in the working directory of every tool you run, and it
eventually ends up in a git commit, a Docker layer, or a Slack message.
You can keep the os.environ reads and drop the file. Two ways, no code
change for the first one:
pip install seekrit
seekrit run -- python app.py # inject into the process, then run it
import seekrit
seekrit.load() # or: load from inside the process
Everything downstream keeps reading os.environ["DATABASE_URL"]. This post
goes through each runtime you're likely to have, and what changes for each.
Why not just .gitignore the .env file?
Because git is one of several ways it leaks. The file is readable by every
package's setup.py, every pytest plugin, every editor extension, and every
coding agent with workspace access. It gets copied into Docker images by a
careless COPY . .. It gets cat'd into a terminal that's being recorded.
And there's one per developer, so nobody knows which values are current.
Encrypting it at rest and decrypting it into the process at startup fixes all of those at once. The value exists in memory while the process runs and nowhere else.
Scripts and CLIs: wrap the command
seekrit run --app analytics --env development -- python etl.py
seekrit run resolves the environment, decrypts locally, puts the values in
the child's environment, and execs the command. Delete load_dotenv() and
the .env; nothing else in the script changes. The dotenv migration
guide has the import command for moving your existing
file over.
For a deployed job, mint a service token bound
to one environment, set it as SEEKRIT_TOKEN, and drop the flags:
SEEKRIT_TOKEN=skt_… seekrit run -- python etl.py
FastAPI and uvicorn
Same thing. The process that needs the environment is uvicorn's, so wrap that:
seekrit run -- uvicorn app.main:app --host 0.0.0.0 --port 8000
If you use pydantic-settings, it already reads from the environment first
and a .env file second. Remove the env_file from model_config and it
keeps working:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str
stripe_secret_key: str
# model_config = SettingsConfigDict(env_file=".env") <- delete this
settings = Settings()
If you'd rather not depend on how the process was launched, resolve from
inside it, before Settings() is constructed:
import seekrit
seekrit.load() # reads SEEKRIT_TOKEN, fills os.environ
settings = Settings()
load() fails loudly if there's no token and no way to prompt for one. A
server that silently started with an empty DATABASE_URL is worse than one
that didn't start.
Django
Django reads settings at import time, so either wrap the launcher:
seekrit run -- python manage.py runserver
seekrit run -- gunicorn mysite.wsgi
or call seekrit.load() at the top of settings.py, before the first
os.environ read. The wrapper is simpler and keeps the SDK out of your
settings module.
In a container
Don't install the Python CLI in the image just for this. Copy the static
seekrit-run binary in and make it the entrypoint:
FROM seekritdev/run:latest AS seekrit
FROM python:3.12-slim
COPY --from=seekrit /seekrit-run /usr/local/bin/seekrit-run
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
ENTRYPOINT ["seekrit-run", "--"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
docker run -e SEEKRIT_TOKEN=skt_… my-api
The image holds no values. The container fetches its own at start.
pytest
Tests that hit a real database or a sandbox API need real credentials. Give them a test environment, not production:
seekrit run --app analytics --env test -- pytest
Or in conftest.py, so pytest alone works in an editor:
import seekrit
def pytest_configure(config):
seekrit.load(prompt=False)
A test suite runs on laptops, in CI, and under coding agents. It should not be able to decrypt production.
Jupyter notebooks
import seekrit
seekrit.load()
# <seekrit: 7 secrets loaded from acme/analytics/staging: API_KEY, DATABASE_URL, …>
load() prompts for a token with getpass if none is set, and the returned
object prints names only, so a saved notebook never contains a value in a cell
output. Details in the notebooks guide.
Reading values in code
When you want the dict rather than the environment:
import seekrit
client = seekrit.Client() # token from SEEKRIT_TOKEN
secrets = client.resolve() # {"DATABASE_URL": "postgres://…", …}
db_url = client.get("DATABASE_URL")
Decryption happens in your process. The API returns ciphertext and a data key wrapped to your token; the service never holds a plaintext value. The SDK is read-only by design: creating and changing secrets happens in the CLI or dashboard with a human's key.
When the code shouldn't see the key at all
Everything above puts the value in os.environ, where any code in the
process can read it. For an LLM agent or anything running generated code, the
Python SDK also ships an httpx transport that swaps a {{seekrit:NAME}}
placeholder for the real key on the way out, so the process holds a string
that does nothing:
import httpx
from openai import OpenAI
from seekrit.transport import SeekritTransport
client = OpenAI(
api_key="{{seekrit:OPENAI_API_KEY}}",
http_client=httpx.Client(
transport=SeekritTransport(allow={"api.openai.com": ["OPENAI_API_KEY"]}),
),
)
It's a weaker boundary than a separate proxy, since it runs in the same
process, but it's a real one: the key is never in os.environ. See
in-process injection.
Summary
| Runtime | Do this |
|---|---|
| Script, CLI, cron job | seekrit run -- python script.py |
| FastAPI / uvicorn / gunicorn | seekrit run -- uvicorn …, or seekrit.load() before settings |
| Django | seekrit run -- python manage.py … |
| Docker | seekrit-run as the entrypoint, token via -e |
| pytest | Wrap with a test environment, or load() in conftest.py |
| Jupyter | seekrit.load() in the first cell |
| Untrusted code / agents | SeekritTransport with placeholders |
In every row the os.environ reads in your code stay as they are. The .env
file goes away.