SecuredKit

Опубліковано: · Оновлено: · автор:

Як захистити .env файл: найкращі практики 2026

Як не допустити витоку секретів із .env у git, Docker-образи й логи, як їх генерувати і що робити спочатку, коли ключ уже злили. Реальні команди і мій деплой.

безпека .env файлу: термінал розробника з відкритим API-ключем, виділеним червоним попередженням

Захист .env файлу зводиться до чотирьох правил: він ніколи не потрапляє в git, ніколи не потрапляє в Docker-образ, кожне значення в ньому згенероване CSPRNG (криптографічно стійким генератором псевдовипадкових чисел), а не набране з клавіатури, і коли щось витекло, ви спершу відкликаєте секрет і тільки потім чистите історію git. Далі подробиці до кожного правила: команди, які я справді запускаю, і схема, на якій цей сайт працює в продакшені.

Звідки насправді витікають секрети з .env

Класичний витік — git add . у репозиторії без запису в .gitignore. Це досі найпоширеніший сценарій, і стосується він не лише публічних репозиторіїв. Звіт GitGuardian State of Secrets Sprawl 2025 нарахував 23,8 млн нових секретів, запушених у публічний GitHub за 2024 рік, але також знайшов секрети в 35% перевірених приватних репозиторіїв проти 4,6% публічних. Приватні репозиторії клонують на ноутбуки, CI-раннери й підрядникам, тож «він приватний» — це не захист.

Ще одна цифра з того самого звіту тривожить більше: 70% секретів, що витекли у 2022 році, досі були робочими. Люди видаляють файл, пушать ще раз і так і не замінюють ключ.

безпека .env файлу: автоматичний сканер обходить репозиторії GitHub у пошуках відкритих dotenv-секретів

Два інші шляхи тихіші. Dockerfile з COPY . . і без .dockerignore кладе файл у шар образу, і будь-хто, хто може зробити pull, його прочитає. А обробник помилок або крок у CI, який друкує оточення, відправляє всі секрети туди, куди збираються ваші логи.

.gitignore, що покриває всі варіанти .env

Фреймворки розмножують .env файли: .env.local, .env.production, .env.test. Ігноруйте всю родину і залиште виняток тільки для шаблону:

.env
.env.*
!.env.example

git check-ignore -v показує, яке саме правило спрацювало. Це зручно, коли вкладений .gitignore перекриває ваш:

$ git check-ignore -v .env.local .env.example
.gitignore:2:.env.* .env.local
.gitignore:3:!.env.example  .env.example

Пастка — файл, закомічений до того, як з'явилося правило. Git і далі його відстежує, а check-ignore про нього мовчить, бо правила ігнорування не діють на файли, які вже відстежуються. git rm --cached .env припиняє відстеження, але старий коміт зберігає значення:

$ git rm --cached .env && git log --oneline -- .env
d1c1d61 init

Якщо цей коміт хоч раз пушили, вважайте кожне значення в ньому публічним і переходьте до розділу про витік нижче. У .env.example тримайте лише назви змінних і явно фейкові значення: справжній ключ у шаблоні — той самий витік, тільки з іншою назвою файлу.

Не пускайте файл у Docker-образ

Multi-stage збірка тут не рятує. Стадія збірки все одно отримує весь build context, а секрет, що потрапив у будь-який шар, який ви пушите, можна витягти. Додайте файл у .dockerignore, щоб він узагалі не потрапляв у build context:

.env
.env.*

Далі передавайте значення під час запуску, а не під час збірки. У Nuxt це просто: усе, що оголошено в runtimeConfig, можна перевизначити відповідною змінною середовища з префіксом NUXT_ під час старту сервера, тож зібраний .output містить порожню заглушку, а не ключ. У більшості фреймворків є такий самий поділ на конфігурацію збірки і конфігурацію запуску. З'ясуйте, з якого боку ваш секрет, бо все, що читається під час збірки, може опинитися в бандлі.

Як я зберігаю секрети цього сайту

Сайт невеликий: один VPS, один контейнер, два секрети (API-ключ сервісу пошти для контактної форми і контактна адреса). Схема навмисно нудна:

  1. Локально значення лежать у .env, який є і в .gitignore, і в .dockerignore. git log --all -- .env у цьому репозиторії нічого не повертає, тобто файл ніколи не комітився.
  2. Продакшн-значення живуть в ansible/group_vars/all/vault.yml, зашифрованому Ansible Vault. Цей файл є в git, його перший рядок — $ANSIBLE_VAULT;1.1;AES256, а все далі — шифротекст.
  3. Файл із паролем від vault внесений у .gitignore. Це єдиний ключ до всього вищезгаданого, тому він ніколи не наближається до репозиторію.
  4. Під час деплою Ansible-плейбук розшифровує vault у пам'яті й передає значення в контейнер як змінні середовища. Сам Docker-образ не містить секретів, а на сервері немає жодного .env файлу.

Компроміс, який я свідомо приймаю: будь-хто з root на цьому сервері прочитає значення через docker inspect. Для VPS однієї людини це нормально, бо зловмисник із root там і так уже контролює все інше. Коли сервісів кілька, людей кілька або є вимоги комплаєнсу, такої схеми вже замало, і менеджер секретів із доступом на рівні сервісу та журналами аудиту виправдовує свою ціну.

Я також пройшов через прибирання протилежної схеми. На одному проєкті API-ключ був прямо в коді. Виправлення складалося з трьох кроків: ключ переїхав у .env, код, що його містив, видалили, а новий ключ згенерували. Останній крок люди пропускають найчастіше, хоча лише він насправді закриває діру.

Генеруйте значення, а не вигадуйте

Секрет, який хтось набрав руками, на кшталт myapp-secret-2026, — це пароль, який можна вгадати, а не ключ. Генеруйте кожне значення через CSPRNG. У командному рядку:

openssl rand -hex 32                      # 256 bits, 64 hex chars
openssl rand -base64 32                   # 256 bits, 44 chars
openssl rand -base64 64 | tr -d '\n'      # 512 bits for HS512

tr тут не для краси. OpenSSL 3.0 переносить base64-вивід кожні 64 символи, тож openssl rand -base64 64 друкує два рядки, і вставлене у .env дворядкове значення тихо перетворюється на обрізаний секрет або на помилку парсингу, залежно від завантажувача.

ПризначенняБітиДовжина hexДовжина base64
Секрет сесії або cookie2566444
Секрет JWT HS2562566444
Секрет JWT HS51251212888
Ключ AES-2562566444

Якщо зручніше клікнути, мій Генератор секретних ключів створює ключі на 128, 256 або 512 біт у hex чи base64 через crypto.getRandomValues(), причому ключ генерується локально у вкладці браузера й нікуди не надсилається. Уникайте генераторів на основі Math.random(): у V8 це xorshift128+, і його внутрішній стан відновлюється з кількох виходів. UUID із Генератора UUID має 122 випадкові біти і годиться як ідентифікатор запиту, але для всього, що підписує або шифрує, беріть нормальний ключ. Теорія — у статті про CSPRNG.

На спільній машині також виконайте chmod 600 .env, щоб інші локальні користувачі не могли його прочитати.

Коли секрет витік: спершу ротація, потім історія

Власна документація GitHub про видалення чутливих даних ставить відкликання на перше місце: щойно старий секрет перестає давати доступ, переписувати історію може й не знадобитися. Порядок, якого я б тримався:

  1. Відкличте або замініть ключ у провайдера. Задеплойте нове значення.
  2. Перегляньте журнали доступу провайдера з моменту коміту.
  3. Лише після цього вирішуйте, чи переписувати історію. GitHub зараз радить git-filter-repo версії 2.47 або новішої з --sensitive-data-removal і попереджає, що форки зберігають коміт, а кешовані сторінки лишаються доступними за SHA, доки їх не прибере підтримка GitHub.

Переписувати історію до ротації — фальшиве відчуття безпеки, бо скрапери читають нові публічні коміти за лічені хвилини. З березня 2024 року GitHub за замовчуванням вмикає secret scanning і push protection для нових публічних репозиторіїв особистих акаунтів, і це блокує багато відомих форматів ключів ще на етапі push. Випадковий hex-рядок він секретом не вважає, тож основну роботу все одно робить .gitignore. Якщо ви самі видаєте API-ключі, стаття про керування API-ключами пояснює префікси, завдяки яким витік ключа легко помітити.

Де має жити кожен секрет

ЕтапДе лежить значенняЯк його отримує застосунок
Локальна розробка.env у .gitignore і .dockerignoredotenv або завантажувач фреймворку
CIСховище секретів CI-платформи, замасковане в логахПередається як змінна середовища для джоби
Продакшн, невеликий проєктЗашифрований файл у git (Ansible Vault, SOPS)Розшифровується під час деплою
Продакшн, багато сервісівVault, AWS чи GCP Secret Manager, DopplerЗапитується під час роботи з доступом на рівні сервісу

На будь-якому етапі хай застосунок падає під час старту, якщо бракує потрібної змінної, а не запускається напівналаштованим і ламається на першому запиті, якому вона потрібна:

const required = ['DATABASE_URL', 'JWT_SECRET']
const missing = required.filter((key) => !process.env[key])
if (missing.length) throw new Error(`Missing env vars: ${missing.join(', ')}`)

Виводьте назви відсутніх змінних, ніколи не їхні значення, і перевірте, що ваш трекер помилок вичищає дані оточення, поки ви не дізналися про це в найгірший момент.

FAQ

Чи можна використовувати .env файл у продакшені?

Можна, якщо файл створюється на сервері під час деплою із зашифрованого джерела і ніколи не потрапляє ні в репозиторій, ні в образ. Уникати варто відкритого продакшн-.env, який копіюють вручну або запікають у шар Docker-образу.

Чи потрібно шифрувати .env файл?

Шифруйте копію, яку зберігаєте, а не ту, яку читає застосунок. SOPS або Ansible Vault тримають у git файл, зашифрований AES, і розшифровують його лише під час деплою, тож витік репозиторію віддасть тільки шифротекст. Ключ для розшифрування має жити поза репозиторієм.

Спробуйте безкоштовний генератор паролів

Генеруйте надійні, безпечні паролі миттєво. 100% приватно, на стороні клієнта.

Відкрити генератор паролів