# Как безопасно хранить API-ключи для LLM-сервисов: .env, менеджеры секретов и прокси

> AI Release · @ai_release1 · https://ai-release.org/guides/kak-bezopasno-hranit-api-klyuchi-dlya-llm-servisov-env.html

## TL;DR
- Время выполнения: 40–60 минут
- Уровень сложности: Intermediate
- Понадобится: Linux/macOS или WSL2; Python 3 + pip (версию сверьте с документацией вашего дистрибутива); пакет `python-dotenv`; `git`; `openssl`; `nginx` и `envsubst` (пакет `gettext`) — для варианта с прокси; доступ к менеджеру секретов (HashiCorp Vault или облачный Secret Manager — команды и синтаксис сверьте с документацией провайдера); действующий API-ключ LLM-провайдера.

## Требования и подготовка
![Иллюстрация к разделу «Требования и подготовка»](https://ai-release.org/guides/img/kak-bezopasno-hranit-api-klyuchi-dlya-llm-servisov-env-ru-1.jpg)

Термины, которые понадобятся дальше:
- **.env** — текстовый файл с парами `ИМЯ=значение`, который читает приложение при старте. Ключ лежит отдельно от кода.
- **Менеджер секретов** — сервис, который хранит ключи централизованно и выдаёт их приложению по запросу (Vault, облачные Secret Manager).
- **Обратный прокси** — сервер, который принимает запрос от вашего приложения и сам подставляет ключ при обращении к провайдеру. Приложение и браузер ключа не видят.

Схема, к которой мы идём:

```
[приложение] --без ключа--> [nginx 127.0.0.1:8080] --Bearer <ключ>--> [API провайдера]
        ^                                                              ^
        |                                                              |
   читает .env (dev)                     ключ подставляет прокси (prod)
```

Проверьте, что инструменты на месте:

```bash
python3 --version
pip --version
git --version
openssl version
nginx -v
envsubst --version
```

**Пример:** ожидаемый вывод — по строке на инструмент, например `git version 2.x.x` и `nginx version: nginx/1.x.x`. Если какая-то команда отвечает `command not found`, установите пакет средствами вашего дистрибутива (`apt install nginx gettext` и т.п.) — конкретные имена пакетов сверьте с документацией ОС.

Почему это работает: все дальнейшие шаги опираются на то, что ключ физически лежит в одном месте, а доступ к нему ограничен правами файловой системы и списком получателей в менеджере секретов.

- [ ] Создан рабочий каталог проекта, например `~/llm-app`
- [ ] Есть действующий API-ключ провайдера
- [ ] Определено, где будет работать приложение: локально, CI или прод

## Шаг 1. Изолируйте ключ в .env и запретите его коммитить

**Что сделать:** создайте в корне проекта три файла: `.env` (реальный ключ), `.env.example` (шаблон без значения) и `.gitignore`.

```bash
cd ~/llm-app
printf 'LLM_API_KEY=\nLLM_BASE_URL=http://127.0.0.1:8080/v1\n' > .env.example
cp .env.example .env
sed -i 's|^LLM_API_KEY=.*|LLM_API_KEY=<ваш-ключ>|' .env
printf '.env\n.env.*\n!.env.example\n' > .gitignore
chmod 600 .env
```

`<ваш-ключ>` — строка, которую выдал провайдер. `chmod 600` оставляет чтение только владельцу файла. Строка `!.env.example` нужна, чтобы шаблон всё-таки попал в репозиторий.

**Зачем:** `.env` в `.gitignore` — единственная защита от самой частой утечки: случайного `git add .`. Шаблон `.env.example` при этом документирует для коллег, какие переменные нужны, не раскрывая значений.

**Как проверить:**

```bash
git check-ignore -v .env
ls -l .env
git status --short
```

Ожидаемый результат: `git check-ignore` печатает строку вида `.gitignore:1:.env	.env`; `ls -l` показывает права `-rw-------`; `git status` показывает `.env.example` и `.gitignore`, но **не** `.env`.

**Пример:** если `git check-ignore -v .env` ничего не вывел, файл не игнорируется — не коммитьте ничего, пока не почините `.gitignore`.

Дополнительно проверьте, что ключ не зашит в коде:

```bash
grep -rn "LLM_API_KEY=" --include="*.py" .
```

Ожидаемо — пусто (ключ должен встречаться только в `.env`).

## Шаг 2. Менеджер секретов и прокси: ключ не попадает в код и в браузер
![Иллюстрация к разделу «Шаг 2. Менеджер секретов и прокси: ключ не попадает в код и в браузер»](https://ai-release.org/guides/img/kak-bezopasno-hranit-api-klyuchi-dlya-llm-servisov-env-ru-2.jpg)


**Что сделать:** перенесите значение из `.env` в менеджер секретов, а на машине приложения поднимайте прокси, который подставляет ключ на лету.

Загрузка секрета (два варианта, синтаксис сверьте с документацией выбранного инструмента):

```bash
# HashiCorp Vault
vault kv put secret/llm api_key=<ваш-ключ>

# AWS Secrets Manager
aws secretsmanager create-secret \
  --name llm/api-key \
  --secret-string '<ваш-ключ>'
```

Приложение читает переменные окружения, а не литералы:

```python
import os
from dotenv import load_dotenv

load_dotenv()                       # в проде .env может отсутствовать
api_key = os.environ["LLM_API_KEY"]  # упадёт с KeyError, если переменной нет
```

Теперь прокси. Создайте шаблон `/etc/nginx/llm.conf.template`:

```nginx
server {
    listen 127.0.0.1:8080;
    location /v1/ {
        proxy_pass https://api.<провайдер>.tld/;
        proxy_set_header Authorization "Bearer ${LLM_API_KEY}";
        proxy_set_header Content-Type "application/json";
    }
}
```

`<провайдер>.tld` замените на хост API вашего поставщика. Подстановка значения и запуск:

```bash
export LLM_API_KEY="$(cat ~/llm-app/.env | grep '^LLM_API_KEY=' | cut -d= -f2-)"
envsubst < /etc/nginx/llm.conf.template > /etc/nginx/conf.d/llm.conf
nginx -t && nginx -s reload
```

**Зачем:** `envsubst` вставляет ключ в конфиг только в момент запуска на сервере, поэтому в git и в образе конфиг лежит с плейсхолдером `${LLM_API_KEY}`. Браузер и фронтенд обращаются к `127.0.0.1:8080` и ключа не получают — его добавляет прокси. `nginx -t` проверяет синтаксис до перезагрузки, чтобы не уронить рабочий процесс.

**Как проверить:**

```bash
grep -c 'Bearer ${LLM_API_KEY}' /etc/nginx/conf.d/llm.conf
grep -c 'Bearer <ваш-ключ>' /etc/nginx/conf.d/llm.conf
```

Ожидаемо: первая команда — `1`, вторая — `0`. Если наоборот, шаблон уже был подставлен и ключ осел в файле на диске: пересоздайте конфиг из шаблона и проверьте права (`chmod 640 /etc/nginx/conf.d/llm.conf`).

## Шаг 3. Проверка работы и финальный результат
![Иллюстрация к разделу «Шаг 3. Проверка работы и финальный результат»](https://ai-release.org/guides/img/kak-bezopasno-hranit-api-klyuchi-dlya-llm-servisov-env-ru-3.jpg)


**Что сделать:** прогоните четыре проверки: работоспособность прокси, отсутствие ключа в истории git, реакцию на неверный ключ и отсутствие ключа в артефактах.

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/v1/models
git log --all --oneline -- .env
grep -rn "sk-" --include="*.py" --include="*.json" --include="*.yml" .
docker history <ваш-образ> 2>/dev/null | grep -i key
```

**Зачем:** каждая команда закрывает свой канал утечки — сетевой, историю репозитория, исходники и слои контейнера.

**Как проверить:** ожидаемые результаты по порядку — `200`; пустой вывод (файл никогда не коммитился); пустой вывод; пустой вывод. Затем убедитесь, что при неверном ключе запрос падает, а не проходит молча:

```bash
LLM_API_KEY=invalid nginx -s reload && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/v1/models
```

Ожидаемо — `401`. Верните рабочий ключ и перезагрузите nginx повторно. Точный формат тела ответа с ошибкой зависит от провайдера — сверьтесь с его документацией.

Итоговое состояние: ключ существует в двух местах — в менеджере секретов и в памяти процесса nginx; в репозитории, в коде и в браузере его нет.

## Возможные проблемы и решения

**Проблема. Приложение падает при старте, хотя .env на месте**
- Ошибка: `ModuleNotFoundError: No module named 'dotenv'`
- Причина: пакет `python-dotenv` установлен в другое окружение (системный Python вместо виртуального).
- Решение: активируйте venv и поставьте пакет внутрь него.
```bash
cd ~/llm-app && python3 -m venv .venv && source .venv/bin/activate && pip install python-dotenv
python -c "import dotenv; print('ok')"
```
Ожидаемый вывод — `ok`.

**Проблема. Push в GitHub блокируется, хотя .env в .gitignore**
- Ошибка: `remote: error: GH013: Repository rule violations found for refs/heads/main.` (формулировка зависит от платформы — сверьтесь с её документацией)
- Причина: ключ попал в один из прошлых коммитов; `.gitignore` не удаляет уже закоммиченное.
- Решение: сначала **отзовите ключ в панели провайдера и выпустите новый** — история уже скомпрометирована. Затем удалите файл из истории:
```bash
git rm --cached .env
git commit -m "remove .env from index"
git filter-repo --path .env --invert-paths
```
После перезаписи истории потребуется `git push --force` в свою ветку; команду согласуйте с владельцем репозитория.

**Проблема. nginx не стартует после правки конфига**
- Ошибка: `nginx: [emerg] "proxy_set_header" directive is not allowed here in /etc/nginx/conf.d/llm.conf:5`
- Причина: директива оказалась вне блока `location`, например после закрывающей скобки.
- Решение: проверьте вложенность и перезапустите проверку.
```bash
nginx -t
```
Ожидаемо: `syntax is ok` и `test is successful`. Только после этого — `nginx -s reload`.

## Резюме и следующие шаги
Ключ хранится в менеджере секретов, подставляется прокси и никогда не попадает в git, код или браузер. Дальше настройте ротацию ключа по расписанию и добавьте проверку `git check-ignore -v .env` в pre-commit хук.

## FAQ
**Можно ли хранить API-ключ в переменной окружения в проде?**
Да, это рабочий минимум, но переменные окружения видны процессам и дампам окружения. Для прода используйте менеджер секретов и выдачу ключа на время жизни процесса.

**Чем .env хуже менеджера секретов?**
`.env` — обычный файл на диске: его легко случайно закоммитить или скопировать. Менеджер секретов даёт централизованный доступ, аудит и отзыв.

**Что делать, если ключ уже попал в git?**
Сначала отозвать ключ у провайдера и выпустить новый, потом чистить историю через `git filter-repo`. Удаление файла новым коммитом проблему не решает.

**Нужен ли прокси, если уже есть менеджер секретов?**
Нужен, если ключ использует фронтенд или сторонний сервис: прокси — единственный способ не отдавать ключ клиенту. Если ключ читает только ваш бэкенд, можно ограничиться менеджером секретов.
