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

Захист .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 році, досі були робочими. Люди видаляють файл, пушать ще раз і так і не замінюють ключ.

Два інші шляхи тихіші. 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-ключ сервісу пошти для контактної форми і контактна адреса). Схема навмисно нудна:
- Локально значення лежать у
.env, який є і в.gitignore, і в.dockerignore.git log --all -- .envу цьому репозиторії нічого не повертає, тобто файл ніколи не комітився. - Продакшн-значення живуть в
ansible/group_vars/all/vault.yml, зашифрованому Ansible Vault. Цей файл є в git, його перший рядок —$ANSIBLE_VAULT;1.1;AES256, а все далі — шифротекст. - Файл із паролем від vault внесений у
.gitignore. Це єдиний ключ до всього вищезгаданого, тому він ніколи не наближається до репозиторію. - Під час деплою 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 |
|---|---|---|---|
| Секрет сесії або cookie | 256 | 64 | 44 |
| Секрет JWT HS256 | 256 | 64 | 44 |
| Секрет JWT HS512 | 512 | 128 | 88 |
| Ключ AES-256 | 256 | 64 | 44 |
Якщо зручніше клікнути, мій Генератор секретних ключів створює ключі на 128, 256 або 512 біт у hex чи base64 через crypto.getRandomValues(), причому ключ генерується локально у вкладці браузера й нікуди не надсилається. Уникайте генераторів на основі Math.random(): у V8 це xorshift128+, і його внутрішній стан відновлюється з кількох виходів. UUID із Генератора UUID має 122 випадкові біти і годиться як ідентифікатор запиту, але для всього, що підписує або шифрує, беріть нормальний ключ. Теорія — у статті про CSPRNG.
На спільній машині також виконайте chmod 600 .env, щоб інші локальні користувачі не могли його прочитати.
Коли секрет витік: спершу ротація, потім історія
Власна документація GitHub про видалення чутливих даних ставить відкликання на перше місце: щойно старий секрет перестає давати доступ, переписувати історію може й не знадобитися. Порядок, якого я б тримався:
- Відкличте або замініть ключ у провайдера. Задеплойте нове значення.
- Перегляньте журнали доступу провайдера з моменту коміту.
- Лише після цього вирішуйте, чи переписувати історію. GitHub зараз радить
git-filter-repoверсії 2.47 або новішої з--sensitive-data-removalі попереджає, що форки зберігають коміт, а кешовані сторінки лишаються доступними за SHA, доки їх не прибере підтримка GitHub.
Переписувати історію до ротації — фальшиве відчуття безпеки, бо скрапери читають нові публічні коміти за лічені хвилини. З березня 2024 року GitHub за замовчуванням вмикає secret scanning і push protection для нових публічних репозиторіїв особистих акаунтів, і це блокує багато відомих форматів ключів ще на етапі push. Випадковий hex-рядок він секретом не вважає, тож основну роботу все одно робить .gitignore. Якщо ви самі видаєте API-ключі, стаття про керування API-ключами пояснює префікси, завдяки яким витік ключа легко помітити.
Де має жити кожен секрет
| Етап | Де лежить значення | Як його отримує застосунок |
|---|---|---|
| Локальна розробка | .env у .gitignore і .dockerignore | dotenv або завантажувач фреймворку |
| 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, і розшифровують його лише під час деплою, тож витік репозиторію віддасть тільки шифротекст. Ключ для розшифрування має жити поза репозиторієм.