API key in Docker: passing it safely without baking it into the image
A key in a Dockerfile stays in the image layers forever. We cover env_file, Compose secrets, build-time secrets, .dockerignore and image checks — with examples for a Zukko key.
5 min · · development · API keys
An API key in Docker usually leaks not through a hack but through the image itself: the key was written into ENV, a .env file was copied inside, or it was passed via ARG — and it stayed in layers that get pushed to a registry. Below are ways to give the key to the container only at runtime, from a simple docker run -e to Docker Compose secrets, and how to check that nothing is left in the image. The examples use a Zukko key and the OpenAI SDK, but the approach is the same for any key.
What not to do in a Dockerfile
ENV OPENAI_API_KEY=fetch_your_key
ARG OPENAI_API_KEY
COPY .env /app/.envAll three lines are bad. ENV is stored in the image configuration and visible to anyone who pulls it. According to Docker's documentation, build arguments and environment variables are not suitable for secrets because they persist in the final image. COPY .env puts the file into a layer; even if you delete it in the next instruction, the earlier layer with the file remains. And of course, the key must not be in the source code.
The rule is simple: the image is built without the key; the key appears only in the running container.
Method 1: a variable at runtime
docker run -e OPENAI_API_KEY="fetch_your_key" \
-e OPENAI_BASE_URL="https://api.zukko.pro/v1" myappThe OpenAI Python client reads OPENAI_API_KEY and OPENAI_BASE_URL on its own, so in code this is enough:
from openai import OpenAI
client = OpenAI()If you prefer explicit values, pass them as arguments: OpenAI(base_url=os.environ["OPENAI_BASE_URL"], api_key=os.environ["OPENAI_API_KEY"]).
Downsides: the key ends up in shell history, and environment variables of a running container are visible via docker inspect to anyone with Docker access on the server.
Method 2: an environment file (env_file)
Create a zukko.env file on the server:
OPENAI_API_KEY=fetch_your_key
OPENAI_BASE_URL=https://api.zukko.pro/v1
Running without Compose:
docker run --env-file zukko.env myappIn Docker Compose, the env_file attribute does the same:
services:
api:
image: myapp:latest
env_file:
- zukko.envThe path is relative to compose.yaml. The file is not committed to the repository and doesn't enter the build context (see the .dockerignore section). Restrict access to it on the server: chmod 600 zukko.env.
The key still becomes a container environment variable, with all the downsides from the previous section.
Method 3: Docker Compose secrets
Docker's documentation explicitly recommends secrets over environment variables for passwords and API keys: variables are available to all processes and can accidentally end up in logs. A secret is mounted into the container as the file /run/secrets/<secret_name> and is available only to services you explicitly grant it to.
services:
api:
image: myapp:latest
environment:
OPENAI_BASE_URL: https://api.zukko.pro/v1
secrets:
- zukko_api_key
secrets:
zukko_api_key:
file: ./secrets/zukko_api_key.txtThe file ./secrets/zukko_api_key.txt contains a single line — the key. Instead of a file, the source can be an environment variable on the host running Compose: environment: ZUKKO_API_KEY in the secret definition.
The application reads the key from the file:
import os
from pathlib import Path
from openai import OpenAI
def read_api_key() -> str:
secret = Path("/run/secrets/zukko_api_key")
if secret.exists():
return secret.read_text().strip()
return os.environ["OPENAI_API_KEY"]
client = OpenAI(base_url="https://api.zukko.pro/v1", api_key=read_api_key())The environment variable fallback is handy for local development without Compose.
If you need the key at build time
Usually an LLM key isn't needed during the build. But if the build, say, generates data through the API, use build secrets — they are available only for a single RUN instruction and don't stay in the image:
RUN --mount=type=secret,id=zukko \
OPENAI_API_KEY="$(cat /run/secrets/zukko)" python scripts/prepare.pydocker build --secret id=zukko,src=./secrets/zukko_api_key.txt -t myapp ..dockerignore and .gitignore
On docker build, the whole context (usually the current folder) is sent to the builder. If a .env file is there, a careless COPY . . can pick it up. The .dockerignore file excludes such files from the context:
.env
.env.*
*.env
secrets/
.git
Add the same to .gitignore (.env, *.env, secrets/). The .dockerignore file itself is committed — everyone who builds the image needs it.
Separate keys per environment
In Zukko you can create several keys and give each a daily and monthly dollar limit and a list of allowed models — see key limits. This fits Docker well:
- a development key — a small daily limit and only a cheap model such as
glm-5.3-flash; - a staging key — its own limit;
- a production key — daily and monthly limits sized for real load.
Each environment gets its own secret file. If a key leaks, delete it on the API keys page, create a new one, update the secret file and restart the service: docker compose up -d. No image rebuild is needed — the key isn't in it. The full key is shown only once at creation, so save it straight into the secret file or a password manager.
If a key exceeds its limit, requests are rejected with 429, and a model outside the allowed list returns 403 model_not_allowed_for_key — you'll see this in the container logs.
Kubernetes
In Kubernetes the key is stored in a Secret object and attached to the pod as a variable or as a file via a volume:
kubectl create secret generic zukko-api-key \
--from-file=api_key=./secrets/zukko_api_key.txt -n productionenv:
- name: OPENAI_BASE_URL
value: "https://api.zukko.pro/v1"
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: zukko-api-key
key: api_keyKeep in mind that by default Secret values are stored base64-encoded, not encrypted; see the Kubernetes documentation for encryption and access settings.
How to check the key isn't in the image
docker history --no-trunc myapp | grep -i "fetch_"
docker image inspect myapp --format '{{json .Config.Env}}'The first command searches layer commands for the key, the second shows variables baked into the image. The key must be in neither. If you find it, delete the key in the dashboard, fix the Dockerfile and rebuild; remove already pushed tags from the registry.
FAQ
Which is safer: env_file or Compose secrets?
Secrets. A value from env_file becomes an environment variable, visible via docker inspect and to every process in the container. A secret is a file available only to the services it is granted to.
Can I pass the key via ARG at build time?
No: build arguments persist in the final image. If the key is needed during the build, use docker build --secret and RUN --mount=type=secret.
Which base URL should the container use?
For the OpenAI SDK and OpenAI-compatible clients — https://api.zukko.pro/v1. For the official Anthropic SDK — https://api.zukko.pro without /v1; the SDK adds it. See the quick start.
How often should I rotate the key?
Immediately on any suspicion of a leak and when someone with access leaves. With secrets, rotation is replacing one file and restarting the service.
zukko
Pay 25×
less
for Claude Code & ChatGPT
One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.
Related articles
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Gemini API in Python: requests via the OpenAI SDK and OpenAI formatA working Gemini API example in Python with no extra library: change base_url in the OpenAI SDK, add streaming and JSON, and compare with google-genai.
- Anthropic SDK for Python: base_url, key and a first request via a gatewayThe official anthropic package for Python just needs a different address. We cover base_url, the key, streaming, token counting, caching and errors.