<? phpukraine СПІВБЕСІДИ
Пошук по платформі
DEVOPS · MIDDLE

Де тримати конфігурацію й секрети PHP-застосунку в різних середовищах?

Конфігурація приходить у процес через змінні оточення й відрізняється по середовищах, код лишається однаковим; .env — це локальна зручність розробника, яку не комітять, а на продакшені значення дає pool php-fpm, оркестратор або менеджер секретів. У Laravel env() дозволений лише у файлах config/, бо після config:cache .env не завантажується взагалі.

Виправили значення в .env на сервері, перезапустили — застосунок далі бачить старе. Що сталося?
Що з цього комітять у git: .env, .env.example, .env.local, config/secrets?
Розробник випадково запушив ключ від платіжного шлюзу й одразу зробив revert. Цього достатньо?
Де тримати пароль до бази в контейнері, якщо змінні оточення видно в `docker inspect` і в `/proc/1/environ`?
.env секрети конфігурація деплой 12-factor

Базовий принцип формулюється в одному реченні: конфігурація — це те, що відрізняється між середовищами, і вона має приходити ззовні, а не з коду. Це третій пункт 12-factor, і практично він означає, що один і той самий артефакт (образ, теґ, архів релізу) виїжджає на staging і на prod без перезбирання, а різниця між ними — виключно у значеннях змінних оточення процесу. Перевірка на зрілість тут проста: якщо у коді є if (app()->environment('production')) навколо адреси сервісу чи ліміту, конфігурація живе в коді, а не зовні.

Технічно PHP отримує ці значення трьома різними каналами, і їх плутають частіше, ніж здається. getenv() читає справжнє оточення процесу — те, що передав php-fpm, systemd чи контейнер. $_ENV наповнюється лише якщо в variables_order є буква E, а і php.ini-development, і php.ini-production постачаються зі значенням "GPCS", тобто без неї — тому на чистому продакшн-конфізі $_ENV порожній, хоча getenv() працює. Третій канал — бібліотека dotenv, яка на старті читає .env і сама наповнює $_ENV та $_SERVER. У Symfony компонент Dotenv за замовчуванням не викликає putenv() (це поведінка з 5.0), тож там значення беруть із $_ENV/$_SERVER, а не через getenv(). Висновок для коду: не звертайтеся до оточення напряму, ходіть через config() або параметри контейнера — тоді джерело можна змінити, не чіпаючи класи.

Конвенції двох головних фреймворків протилежні, і на співбесіді це люблять питати. У Laravel .env завжди в .gitignore, у репозиторії лежить .env.example зі списком ключів і безпечними значеннями; env() дозволений тільки у файлах config/, бо на деплої виконується php artisan config:cache, після чого .env не читається взагалі — Laravel бачить закешований bootstrap/cache/config.php і пропускає завантаження оточення. Звідси й класичний симптом «змінив .env, а нічого не змінилося»: треба config:clear або повторний config:cache. У Symfony ж .env комітять — це файл дефолтів, а локальні відхилення йдуть у .env.local, який у .gitignore; для продакшену composer dump-env prod (з symfony/flex) згортає все у скомпільований .env.local.php, щоб не парсити файли на кожному запиті.

Для справжніх секретів обидві екосистеми мають шифроване сховище, а індустріальний варіант — зовнішній менеджер. Symfony від 4.4 має vault на libsodium: bin/console secrets:set DATABASE_PASSWORD кладе sealed box у config/secrets/prod/, публічний ключ шифрування комітять, приватний prod.decrypt.private.php — ні (він доставляється окремо або передається через SYMFONY_DECRYPTION_SECRET). Laravel від 9.32 має php artisan env:encrypt --env=production, що робить .env.production.encrypted під AES-256-CBC, а розшифрування на деплої бере ключ зі змінної LARAVEL_ENV_ENCRYPTION_KEY. У хмарі ці файли часто не потрібні зовсім: AWS Secrets Manager, GCP Secret Manager чи HashiCorp Vault віддають значення інстансу, який автентифікувався роллю, — і тоді кореневого пароля на диску немає взагалі, а є короткоживучий токен. Окремо: секрет ніколи не потрапляє в ENV/ARG Dockerfile, бо лишається в шарі образу й читається через docker history; для збірки є RUN --mount=type=secret, для рантайму — файл у /run/secrets і патерн *_FILE.

Межі підходу варто назвати самому. Змінні оточення — не броня: їх успадковують дочірні процеси, вони читаються з /proc/<pid>/environ, потрапляють у краш-дампи й у сторінку помилки, якщо на проді лишили APP_DEBUG=true. Тому найчутливіше (приватні ключі, сертифікати) віддають файлом з правами 0400, а не змінною. Друга межа — час життя: значення, що не змінювалося рік, поводиться як пароль, який знають усі, хто колись мав доступ; тому ротація має бути плановою, і кожен секрет має пару «поточний + попередній», щоб її пережити без даунтайму — у Laravel це APP_PREVIOUS_KEYS для APP_KEY, у баз — окремий користувач на застосунок замість спільного. І третє, найважливіше правило інциденту: щойно секрет потрапив у пуш, він скомпрометований, і першою дією є відкликання ключа в провайдера, а не git filter-repo — переписана історія не забирає значення з форків, кешу CI, локальних клонів і логів вебхуків.

<?php
// config/services.php — єдине місце, де дозволено env().
return [
    'stripe' => [
        // Значення без дефолту: на проді має бути задане ззовні.
        'secret' => env('STRIPE_SECRET'),
        // Несекретна конфігурація: дефолт прямо тут, у .env лише відхилення.
        'webhook_tolerance' => (int) env('STRIPE_WEBHOOK_TOLERANCE', 300),
        // Патерн *_FILE: секрет змонтований файлом (Docker/K8s), не змінною.
        'signing_key' => ($p = env('STRIPE_SIGNING_KEY_FILE'))
            ? trim(file_get_contents($p))
            : env('STRIPE_SIGNING_KEY'),
    ],
];

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->singleton(StripeGateway::class, fn ($app) => new StripeGateway(
        // config() читає закешований масив і працює після config:cache.
        $app['config']->get('services.stripe.secret')
            ?? throw new RuntimeException('STRIPE_SECRET не заданий'),
    ));
}

// app/Billing/StripeGateway.php
final class StripeGateway
{
    public function __construct(private readonly string $secret) {}

    // ПОМИЛКА, на якій валяться:
    // public function __construct()
    // {
    //     // Після `php artisan config:cache` файли config/ не виконуються,
    //     // .env не завантажується — env() поверне null мовчки, без винятку,
    //     // і впаде вже HTTP-запит до Stripe із 401.
    //     $this->secret = env('STRIPE_SECRET');
    // }
}
Розділення конфігурації (адреси, ліміти, фіче-флаги) і секретів (паролі, ключі API): перше може лежати у репозиторії з дефолтами, друге — ніколи.
Що один артефакт (образ, теґ) деплоїться в усі середовища без перезбирання, а різниця між dev/staging/prod — тільки в значеннях змінних оточення.
Що `.env` — це файл для локальної розробки, який читає бібліотека dotenv на старті; у продакшені змінні краще віддавати процесу зовні: `env[...]` у пулі php-fpm, systemd, secret у Kubernetes, AWS Secrets Manager чи HashiCorp Vault.
Laravel: `.env` у .gitignore, `.env.example` у git, `env()` тільки в `config/`, на деплої `php artisan config:cache`. Symfony навпаки: `.env` комітять із безпечними дефолтами, секрети йдуть у `.env.local` або в sodium-сховище `secrets:set`.
Що витік секрету лікується ротацією, а не переписуванням історії git: щойно значення потрапило в пуш, воно скомпрометоване назавжди.
Викликати `env('STRIPE_SECRET')` у контролері чи сервісі: після `php artisan config:cache` .env не читається взагалі й виклик тихо поверне `null` — без винятку, з падінням уже на боці API.
Закомітити `.env` «тимчасово, щоб колега підняв стейджинг» і залишити його в історії репозиторію назавжди.
Прибрати секрет через `git revert` або `commit --amend` і вважати інцидент закритим, не відкликавши ключ.
Класти секрет у `ENV`/`ARG` в Dockerfile: значення лишається в шарі образу й видно в `docker history` та `docker inspect` кожному, хто має доступ до реєстру.
Використовувати одні й ті самі креденшели для dev і prod або тягнути дамп продакшену на ноутбук «щоб відтворити баг».
Змінити `.env` на сервері з увімкненим кешем конфігурації й не запустити `config:clear`/`config:cache` — застосунок працює зі старим масивом.
ПОРАДА

Сформулюйте так: «код однаковий у всіх середовищах, різні лише значення змінних оточення, і жодне з них не живе в репозиторії». Далі додайте фразу, за якою чути досвід: «витік секрету закривається ротацією, а не переписуванням історії» — і поясніть, що на продакшені `.env` взагалі може не бути, бо змінні віддає pool php-fpm або оркестратор.

оновлено 5 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Кеш конфігурації робить читання .env непотрібним, тож Laravel його пропускає; env() поза config/ тихо віддає null або дефолт — саме тому правило «env() тільки в config/» існує.