seekrit
← all posts

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

RuntimeDo this
Script, CLI, cron jobseekrit run -- python script.py
FastAPI / uvicorn / gunicornseekrit run -- uvicorn …, or seekrit.load() before settings
Djangoseekrit run -- python manage.py …
Dockerseekrit-run as the entrypoint, token via -e
pytestWrap with a test environment, or load() in conftest.py
Jupyterseekrit.load() in the first cell
Untrusted code / agentsSeekritTransport with placeholders

In every row the os.environ reads in your code stay as they are. The .env file goes away.