Новинка Виртуальный VDS сервер в Нидерландах от 390р
Выберите продукт

systemd credentials на Linux VDS: как уйти от .env с правами 644

Файл .env с правами 644 удобен, но опасен: секреты читаются лишними пользователями, попадают в бэкапы и иногда оказываются в веб-корне. Разбираем безопасную замену через systemd credentials на Linux VDS.
systemd credentials на Linux VDS: как уйти от .env с правами 644

На небольшом Linux VDS часто всё начинается просто: приложение запускается как systemd service, рядом лежит .env, в нём пароль к базе, токен API, ключ сессий, а права у файла случайно остаются 644. Сервис работает, деплой проходит, мониторинг зелёный. Проблема в том, что такой файл читается не только владельцем, но и любым локальным пользователем системы.

Для одиночного проекта это может казаться неважным, но на реальном сервере быстро появляются отдельные пользователи для деплоя, CI, SFTP, резервного копирования, веб-панели, cron-задач и временных утилит. Чем больше ролей на сервере, тем хуже выглядит идея держать продакшен-секреты в файле, который читается «остальными».

systemd credentials — штатный механизм systemd для передачи секретов сервисам без хранения их в обычном .env и без раздачи файлов с широкими правами. Он не заменяет полноценный менеджер секретов в большой инфраструктуре, но отлично закрывает типовую задачу на одном VDS: аккуратно передать пароли приложению, сократить поверхность утечки и сделать конфигурацию понятной для администратора.

Короткая идея: секрет хранится в отдельном файле с жёсткими правами или в зашифрованном виде, а systemd при старте сервиса кладёт его во временный каталог /run/credentials/.... Приложение читает секрет как файл, а не как строку из публичного .env.

Почему .env с правами 644 — плохая привычка

Права 644 означают: владелец может читать и писать файл, группа и остальные пользователи могут читать. Для конфигов без секретов это нормально. Для файла с DB_PASSWORD, APP_KEY, JWT_SECRET, SMTP-паролем или токеном интеграции — нет.

Типовые сценарии утечки выглядят прозаично. Файл случайно попал в Git, потому что .gitignore обновили позже. Каталог приложения оказался доступен через веб-сервер из-за ошибки в root или alias. Бэкап с конфигурацией уехал в общее хранилище. Деплой-пользователь, которому нужен только git pull, получил возможность читать секреты продакшена. Локальный скрипт диагностики отправил архив с .env в тикет или чат.

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

Что такое systemd credentials

systemd credentials — это набор директив unit-файла и утилита systemd-creds. В unit-файле можно указать, какие секреты нужно передать сервису: через LoadCredential, LoadCredentialEncrypted, SetCredential или SetCredentialEncrypted. При запуске systemd создаёт для сервиса отдельный каталог с credential-файлами и сообщает путь к нему через переменную CREDENTIALS_DIRECTORY.

Для приложения это выглядит как обычный файл. Например, если credential называется db_password, сервис сможет прочитать файл $CREDENTIALS_DIRECTORY/db_password. В отличие от .env, этот каталог создаётся на время работы сервиса, находится в runtime-области и изолируется настройками systemd. Администратор контролирует, откуда берётся секрет и кому он доступен на диске.

Важно понимать границы механизма. Если приложение после чтения секрета пишет его в лог, отдаёт в ошибке 500 или сохраняет в кэш, systemd уже не поможет. Credentials решают задачу безопасной доставки секрета до процесса, но не исправляют небезопасное обращение с секретом внутри приложения.

Схема передачи секрета приложению через systemd credentials

Проверяем версию systemd

Базовые credentials доступны в современных дистрибутивах. Шифрование через systemd-creds и директивы с суффиксом Encrypted требуют достаточно свежего systemd. Перед внедрением проверьте версию:

systemctl --version

На актуальных Debian, Ubuntu, AlmaLinux, Rocky Linux, Oracle Linux, CentOS Stream и Fedora обычно всё уже есть. Если сервер давно не обновлялся, сначала проверьте поддержку директив:

man systemd.exec

Найдите в документации разделы LoadCredential, LoadCredentialEncrypted, SetCredential и SetCredentialEncrypted. Если man-страница их не знает, unit-файл не загрузится или будет вести себя не так, как вы ожидаете.

Минимальная схема: LoadCredential из файла

Начнём с варианта без шифрования. Он уже лучше, чем .env 644: секрет лежит отдельно, имеет права 600, не попадает в общий файл окружения и передаётся сервису как временный credential-файл.

Допустим, у нас есть приложение myapp, которое запускается отдельным пользователем. Создадим системного пользователя и каталог для закрытых файлов.

Debian/Ubuntu, RHEL-based и Fedora

sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin myapp

На некоторых RHEL-based системах путь к nologin может быть другим:

getent passwd nobody

Если в вашей системе используется /sbin/nologin, укажите его при создании пользователя или измените существующего пользователя командой usermod.

sudo install -d -m 0750 -o root -g myapp /etc/myapp/credentials

Теперь положим пароль в файл. В реальной работе не вставляйте секрет в историю shell. Удобнее открыть редактор от root или использовать интерактивный ввод. Для примера создадим пустой файл с правильными правами и откроем его через sudoedit:

sudo install -m 0600 -o root -g root /dev/null /etc/myapp/credentials/db_password
sudoedit /etc/myapp/credentials/db_password

Проверяем права:

sudo ls -l /etc/myapp/credentials/db_password
sudo namei -l /etc/myapp/credentials/db_password

Файл должен быть доступен root. Самому пользователю myapp исходный файл читать не обязательно: systemd прочитает его как менеджер сервисов и передаст в runtime-каталог при старте.

Unit-файл с LoadCredential

Создадим unit. Здесь нет EnvironmentFile и нет .env. Сервис получает только путь к каталогу credentials.

[Unit]
Description=MyApp example with systemd credentials
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
LoadCredential=db_password:/etc/myapp/credentials/db_password
ExecStart=/opt/myapp/bin/myapp
Restart=on-failure
RestartSec=5s
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/myapp /var/log/myapp

[Install]
WantedBy=multi-user.target

Сохраняем его как /etc/systemd/system/myapp.service, затем проверяем и запускаем:

sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service

Во время работы сервиса systemd создаст каталог credentials. Его путь можно посмотреть так:

sudo systemctl show myapp.service -p MainPID
sudo strings /proc/$(systemctl show -p MainPID --value myapp.service)/environ | grep '^CREDENTIALS_DIRECTORY='

Здесь команда читает окружение основного процесса, чтобы увидеть только техническую переменную CREDENTIALS_DIRECTORY. Сам секрет в окружении не хранится.

Как приложению читать credential

Лучший вариант — научить приложение читать секреты из файлов. Многие современные фреймворки поддерживают суффиксы вроде _FILE: например, вместо DB_PASSWORD можно передать путь в DB_PASSWORD_FILE. Если такой поддержки нет, добавьте небольшой слой конфигурации.

В Python это может выглядеть так:

import os
from pathlib import Path

creds = Path(os.environ['CREDENTIALS_DIRECTORY'])
db_password = (creds / 'db_password').read_text().strip()

В Node.js:

import fs from 'node:fs';
import path from 'node:path';

const credentialsDirectory = process.env.CREDENTIALS_DIRECTORY;
const dbPassword = fs.readFileSync(path.join(credentialsDirectory, 'db_password'), 'utf8').trim();

В PHP:

$credentialsDirectory = getenv('CREDENTIALS_DIRECTORY');
$dbPassword = trim(file_get_contents($credentialsDirectory . '/db_password'));

Такой подход делает секрет обычным файлом для чтения, но не превращает его в глобальную переменную окружения. Это особенно полезно для приложений, которые запускают фоновые воркеры, миграции, очереди и дочерние процессы.

FastFox VDS
Облачный VDS-сервер
Виртуальные серверы с быстрым запуском и гибкой конфигурацией от 390₽ / мес
Доступные локации
Россия Нидерланды

Если приложение умеет только env

Иногда быстро переписать конфигурацию нельзя: фреймворк или старый код ожидает именно DB_PASSWORD в окружении. Тогда credentials всё равно могут помочь убрать секрет с диска из .env 644, но финальный процесс всё равно получит секрет как переменную окружения. Это компромисс, а не идеальная модель.

Не передавайте секрет в аргументах командной строки: такие аргументы часто видны через ps и попадают в диагностический вывод. Лучше использовать wrapper-скрипт, который читает credential-файл и экспортирует переменную только перед запуском приложения.

#!/bin/sh
set -eu
DB_PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/db_password")
export DB_PASSWORD
exec /opt/myapp/bin/myapp

Скрипт, например /opt/myapp/bin/start-myapp, должен принадлежать root и быть исполняемым:

sudo chown root:root /opt/myapp/bin/start-myapp
sudo chmod 0755 /opt/myapp/bin/start-myapp

В unit-файле меняем только ExecStart:

ExecStart=/opt/myapp/bin/start-myapp

После этого не забудьте выполнить:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

Повторимся: это лучше, чем хранить секрет в публичном .env, но хуже, чем чтение секрета напрямую из файла. Если есть возможность, планируйте миграцию приложения на file-based secrets.

Шифрованные credentials: LoadCredentialEncrypted

Обычный LoadCredential защищает права доступа, но секрет на диске всё равно лежит в открытом виде. Если вы хотите хранить credential-файл в зашифрованном виде, используйте systemd-creds и директиву LoadCredentialEncrypted.

Это удобно, когда конфигурация сервиса хранится в репозитории инфраструктуры, а секреты нужно держать рядом, но не открытым текстом. Также это снижает риск утечки из резервных копий /etc. При этом важно помнить: systemd должен иметь ключ для расшифровки на конкретной машине. Если вы перенесёте зашифрованный файл на другой VDS без нужного ключевого материала, он может не расшифроваться.

Создадим зашифрованный credential из уже существующего файла:

sudo systemd-creds encrypt --name=db_password /etc/myapp/credentials/db_password /etc/myapp/credentials/db_password.cred

Проверим, что файл появился, и ограничим права:

sudo chown root:root /etc/myapp/credentials/db_password.cred
sudo chmod 0600 /etc/myapp/credentials/db_password.cred
sudo ls -l /etc/myapp/credentials/db_password.cred

Теперь в unit-файле заменяем LoadCredential на LoadCredentialEncrypted:

[Service]
LoadCredentialEncrypted=db_password:/etc/myapp/credentials/db_password.cred

После изменения unit-файла:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service
sudo journalctl -u myapp.service -n 50 --no-pager

Если сервис стартует, приложение видит тот же файл $CREDENTIALS_DIRECTORY/db_password. Для приложения не важно, был исходный credential открытым или зашифрованным: расшифровку делает systemd до запуска процесса.

SetCredentialEncrypted: секрет прямо в unit или drop-in

SetCredentialEncrypted позволяет положить зашифрованное значение прямо в unit-файл или drop-in. Это удобно для небольших значений: токена, пароля, ключа интеграции. Преимущество — меньше отдельных файлов. Недостаток — unit становится более шумным, а длинные значения хуже читать и ревьюить.

Пример схемы работы:

printf '%s' 'replace-with-real-secret' | systemd-creds encrypt --name=api_token - -

Команда выведет зашифрованный блок. Его можно вставить в drop-in:

[Service]
SetCredentialEncrypted=api_token:WhxqhtSp8ExampleEncryptedCredentialData

В реальности строка будет длиннее. Не используйте примерное значение из статьи: оно только показывает формат. После добавления drop-in приложение сможет прочитать $CREDENTIALS_DIRECTORY/api_token.

Для несекретных коротких значений существует SetCredential, но его не стоит использовать для паролей: значение хранится в unit-файле открытым текстом. Хорошее применение SetCredential — передать небольшой идентификатор окружения, имя кластера или путь, который не является тайной.

Зашифрованный credential-файл и настройка LoadCredentialEncrypted

Миграция с .env: практичный порядок

Не обязательно переписывать всё за один вечер. Безопаснее мигрировать по одному классу секретов и каждый шаг проверять. Рабочий порядок такой:

  1. Найдите все .env, .env.production, config.php, YAML/INI-файлы и переменные в unit-файлах, где есть секреты.
  2. Разделите настройки на секретные и несекретные. Порт, режим логирования и имя окружения можно оставить в обычной конфигурации. Пароли и токены — вынести.
  3. Создайте каталог /etc/APP/credentials с доступом только для root или узкой группы администраторов.
  4. Перенесите один секрет в credential-файл и добавьте LoadCredential в unit.
  5. Научите приложение читать файл из CREDENTIALS_DIRECTORY или временно используйте wrapper.
  6. Перезапустите сервис, проверьте логи и функциональный тест.
  7. Удалите секрет из .env, истории shell, CI-переменных, временных архивов и старых бэкапов там, где это возможно.
  8. После стабилизации включите LoadCredentialEncrypted для секретов, которые не должны лежать на диске открытым текстом.

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

Ротация секрета без хаоса

Ротация зависит от приложения и внешней системы. Для базы данных обычно нужно создать новый пароль, дать приложению время переключиться, затем удалить старый. Для API-токенов часто можно выпустить второй токен, заменить credential, перезапустить сервис и отозвать старый.

Если используется обычный файл с LoadCredential, обновление выглядит так:

sudoedit /etc/myapp/credentials/db_password
sudo systemctl restart myapp.service
sudo journalctl -u myapp.service -n 100 --no-pager

Если используется зашифрованный файл, сначала обновите открытый временный источник в защищённом месте или введите секрет через stdin, затем пересоздайте .cred. После проверки удалите временный открытый файл.

sudo systemd-creds encrypt --name=db_password /root/db_password.new /etc/myapp/credentials/db_password.cred
sudo chmod 0600 /etc/myapp/credentials/db_password.cred
sudo systemctl restart myapp.service
sudo rm -f /root/db_password.new

Для сервисов, которые поддерживают graceful reload, можно использовать ExecReload, но не все приложения перечитывают credentials при reload. Часто проще и честнее делать контролируемый restart с healthcheck и быстрым rollback.

Права доступа и sandbox-настройки

Credentials хорошо сочетаются с hardening-настройками systemd. Если вы уже выносите секреты, заодно проверьте, что сервис не имеет лишнего доступа к файловой системе и домашним каталогам. Подробно о практическом ужесточении unit-файлов мы разбирали в материале про systemd sandbox и hardening на VDS.

[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/myapp /var/log/myapp
CapabilityBoundingSet=
RestrictSUIDSGID=yes
LockPersonality=yes

Не включайте параметры вслепую. Например, ProtectSystem=strict сделает большую часть файловой системы read-only для сервиса, поэтому приложению нужно явно разрешить запись в рабочие каталоги через ReadWritePaths. Если приложение пишет uploads, кэш или SQLite-базу, эти пути должны быть перечислены.

Для сервисов с DynamicUser=yes credentials тоже полезны, но нужно аккуратно проектировать постоянные каталоги через StateDirectory, CacheDirectory и LogsDirectory. Не пытайтесь вручную угадать UID динамического пользователя.

Диагностика: что проверять, если сервис не стартует

Первое место для диагностики — журнал unit-а:

sudo journalctl -u myapp.service -b --no-pager

Если ошибка появилась сразу после добавления credentials, проверьте unit-файл:

sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl cat myapp.service

Частые причины:

  • Слишком старая версия systemd не знает LoadCredentialEncrypted или SetCredentialEncrypted.
  • В LoadCredential указан неправильный путь к исходному файлу.
  • Имя credential в unit не совпадает с именем, под которым приложение ищет файл.
  • Для зашифрованного credential использовано другое имя в systemd-creds encrypt --name=....
  • После правки unit-файла забыли выполнить systemctl daemon-reload.
  • Приложение запускается через wrapper, но в нём есть ошибка shell или нет execute-права.

Проверить наличие runtime-каталога можно во время работы сервиса. Обычно он находится в /run/credentials/имя.service, но лучше не зашивать этот путь в код, а использовать CREDENTIALS_DIRECTORY.

sudo systemctl show myapp.service -p MainPID -p FragmentPath
sudo ls -la /run/credentials/myapp.service

Если каталога нет, значит credentials не были подключены или сервис не дошёл до стадии запуска процесса. Если каталог есть, но приложение утверждает, что файла нет, почти всегда проблема в имени credential или в том, что процесс запускается не тем unit-ом, который вы редактировали.

Что делать со старым .env

После миграции не оставляйте старый файл как «резервный». Именно такие резервные копии потом и находят в неожиданных местах. Если в .env остались только несекретные настройки, переименуйте его во что-то менее двусмысленное, например app.conf, и проверьте права. Если там когда-либо были секреты, считайте их скомпрометированными в рамках вашей модели риска и запланируйте ротацию.

Минимальная проверка на сервере:

sudo find /opt /var/www /srv -name '.env' -o -name '.env.*'
sudo find /opt /var/www /srv -type f -perm -004 -name '*env*'

Если сайт обслуживается из /var/www, отдельно убедитесь, что веб-сервер не отдаёт скрытые файлы. Но правильнее не держать секреты внутри document root вообще. Конфигурация приложения, runtime-данные, uploads и публичные файлы должны быть разделены.

Итоговая рекомендация для VDS

Для одиночного Linux VDS оптимальная практика выглядит так: несекретные параметры оставляем в обычном конфиге, секреты переносим в systemd credentials, права на исходные файлы делаем 600, приложение учим читать файлы из CREDENTIALS_DIRECTORY. Если версия systemd позволяет, наиболее чувствительные значения храним через LoadCredentialEncrypted или SetCredentialEncrypted.

Это не усложняет эксплуатацию так сильно, как отдельное секрет-хранилище, но убирает самую неприятную ошибку — .env с правами 644 в каталоге приложения. А ещё делает запуск сервиса самодокументируемым: открыл unit-файл и сразу видно, какие credentials нужны процессу и откуда они берутся.

Главное — не воспринимать credentials как магический сейф. Они дают аккуратный и штатный канал доставки секретов в systemd service. Остальное остаётся за администратором: минимальные права, чистые логи, регулярная ротация, бэкапы без лишних копий секретов и понятная инструкция для следующего человека, который будет обслуживать этот сервер.

Поделиться статьей

Вам будет интересно

MySQL и MariaDB на VDS: bind-address, TLS/SSL и firewall для безопасного удалённого доступа OpenAI Статья написана AI (GPT 5)

MySQL и MariaDB на VDS: bind-address, TLS/SSL и firewall для безопасного удалённого доступа

Разбираем практический сценарий: база MySQL или MariaDB стоит на VDS, а подключаться к ней нужно с другого сервера. Покажем, как н ...
Docker на Linux VDS: что занимает /var/lib/docker и как безопасно очистить место OpenAI Статья написана AI (GPT 5)

Docker на Linux VDS: что занимает /var/lib/docker и как безопасно очистить место

Если Docker на VDS внезапно съел диск, не спешите удалять каталог вручную. Разберём, где искать расход места в /var/lib/docker, ка ...
Nginx maintenance mode на VDS: 503, Retry-After, whitelist и health checks OpenAI Статья написана AI (GPT 5)

Nginx maintenance mode на VDS: 503, Retry-After, whitelist и health checks

Разбираем безопасный режим обслуживания в Nginx для VDS: как включать 503 без поломки SSL-обновлений, пропускать админов по whitel ...