РУКОВОДСТВО ПО БЕЗОПАСНОСТИ СЕРВЕРОВ

Docker Compose на VPS: безопасная настройка малых приложений

Коротко: чтобы безопасно запустить приложение на Docker Compose на VPS, публикуйте наружу только то, что нужно обратному прокси, и привязывайте это к 127.0.0.1, не публикуйте порт базы данных, храните секреты в файлах с жёсткими правами, запускайте приложение не от root, фиксируйте версии образов и проверяйте результат снаружи сервера. Docker открывает порты в обход файрвола, поэтому работу, которую не сделает ufw, должен сделать сам compose-файл.

Docker Compose запускает несколько контейнеров из одного YAML-файла. ИИ-ассистенты пишут его для ноутбука, а на сервере он работает без изменений, и в этом проблема. Это слой compose-файла в общем чек-листе безопасности вайб-кодинга. О самом демоне Docker (сокет, rootless-режим, DOCKER-USER) читайте в статье безопасность Docker-хоста на VPS.

Быстрый результат (15 минут)

  • Выполните docker ps --format "table {{.Names}}\t{{.Ports}}" и поищите 0.0.0.0 или [::] в колонке портов.
  • Удалите все записи ports: у сервиса базы данных. См. шаг 1.
  • Замените "3000:3000" на "127.0.0.1:3000:3000" и выполните docker compose up -d, чтобы контейнер пересоздался.
  • Замените latest на точный тег и уберите пароли, записанные в файле. См. шаг 3.
  • С ноутбука выполните nc -zv -w 3 IP_ВАШЕГО_СЕРВЕРА 5432. Подключение должно не удаться.
Сравнение compose-файла от ИИ и усиленного файла для VPS: порты, теги образов, секреты, пользователь и сети
Пять изменений, которые это руководство вносит в типичный compose-файл от ИИ.

Почему compose-файл для ноутбука небезопасен на VPS

Compose-файл для ноутбука рассчитан на удобство: все порты доступны, пароли короткие, папка с исходниками примонтирована, dev-сервер работает от root. На VPS это ломается в трёх местах.

  • Опубликованные порты обходят файрвол. Опубликованный порт это порт контейнера, привязанный к порту хоста через ports:. В документации Docker о фильтрации пакетов и файрволах сказано, что трафик контейнера отводится раньше, чем доходит до правил ufw, а страница о публикации портов называет её небезопасной по умолчанию. Чистый вывод ufw status здесь ничего не доказывает.
  • Секреты утекают сбоку в Git, чаты и скриншоты, а цикл перезапусков или лог, забивающий диск, никто не замечает.

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

Типичный файл от ИИ с пояснениями

Такой файл ассистент пишет на просьбу «приложение с базой Postgres». Он работает с первого раза, поэтому его запускают как есть. Номера совпадают с шагами ниже. Ключ и пароль вымышленные.

services:
  app:
    build: .
    # 1. Dev-сервер, а не боевой запуск
    command: npm run dev
    ports:
      # 2. Адрес хоста не задан: порт открыт везде, ufw обходится
      - "3000:3000"
    environment:
      # 3. Секреты прямо в файле
      - DB_PASSWORD=password
      - STRIPE_SECRET_KEY=sk_test_EXAMPLE_NOT_A_REAL_KEY
    volumes:
      # 4. Вся папка проекта (.env, .git), с записью
      - .:/app
    # 5. Ждёт запуска базы, а не её готовности
    depends_on:
      - db
    # 6. Нет user:, значит root, если образ не задаёт иное
  db:
    # 7. Плавающий тег
    image: postgres:latest
    ports:
      # 8. База открыта всему интернету
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: password
    volumes:
      # 9. Данные внутри папки проекта
      - ./pgdata:/var/lib/postgresql/data
# 10. Нет healthcheck, лимитов логов, сетей и ресурсов

Порты 3000 и 5432 открыты в интернет (пункты 2 и 8), учётные данные лежат открытым текстом (3), процесс идёт от root (6), а образ базы может измениться без вашего ведома (7). Строка version: риском не является: Compose называет её устаревшей и лишь предупреждает, поэтому в новых файлах её не нужно.

Шаг 1: публикуйте только то, что нужно прокси

В справочнике Compose по сервисам короткий синтаксис записан как [HOST:]CONTAINER. Без адреса хоста порт привязывается ко всем интерфейсам, и, как отмечено в документации, это обходит правила файрвола хоста. expose другое: он лишь объявляет порты для связанных сервисов, их не следует публиковать на хосте.

Путь трафика Compose-приложения на VPS: интернет, nginx на 80 и 443, приложение на 127.0.0.1:3000 и база во внутренней сети
В интернет смотрит только nginx; приложение слушает loopback, а у базы нет опубликованного порта.
  • Уберите ports: у базы. Контейнеры в одной сети обращаются к ней как db:5432. Docker подтверждает: контейнеры одной bridge-сети видят друг друга, а неопубликованные порты снаружи хоста недоступны.
  • Привяжите приложение к loopback: "127.0.0.1:3000:3000" это адрес хоста, порт хоста, порт контейнера. По словам Docker, такой порт доступен только с самого хоста Docker. Берите привязку в кавычки, как требует справочник, чтобы YAML не понял её неверно.
  • Поставьте перед ним на хосте обратный прокси. nginx слушает 80 и 443 и пересылает запросы на loopback-порт:
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
    }
    Обе директивы описаны в модуле proxy nginx. Добавьте HTTPS через Let's Encrypt и настройки из статьи безопасность nginx.

Ограничение: loopback скрывает порт от интернета, но не от других пользователей и процессов на сервере, а правка обратно на "3000:3000" молча отменит защиту. Оставьте ufw включённым для SSH и всего, что не контейнер (руководство по файрволу Linux). Чаще всего страдают базы: открытые базы данных на VPS.

Шаг 2: поместите базу во внутреннюю сеть

Если сети не объявлены, все сервисы попадают в одну неявную сеть default и находят друг друга по имени, так что один взломанный контейнер достаёт до всех. Памятка OWASP по безопасности Docker советует отдельные сети только для тех контейнеров, которым нужно общаться.

  • Добавьте сеть backend с internal: true. По справочнику по сетям это создаёт сеть, изолированную от внешнего мира. Подключите к ней только базу. Приложение входит в frontend и backend.
  • Дайте базе healthcheck и дождитесь его. pg_isready сообщает кодом возврата, принимает ли PostgreSQL соединения. С condition: service_healthy приложение стартует после этого; справочник предупреждает, что короткая форма depends_on, использованная в сгенерированном файле, не ждёт.

Ограничение: внутренняя сеть отрезает базу и от интернета, поэтому задачи с исходящим доступом нужно запускать в другом месте.

Шаг 3: уберите секреты из compose-файла

Секрет это любое значение, подтверждающее право приложения что-то делать: пароль, ключ API, токен. Три места для хранения, от худшего к лучшему:

  1. Прямо в compose-файле. Избегайте. Учтите также, что docker compose config печатает разобранный файл, включая значения из вашего .env, поэтому не вставляйте его вывод в чат.
  2. env_file, файл со строками KEY=value вне Git. Годится, но со слабым местом: значения становятся переменными окружения, которые, как сказано в руководстве Docker по секретам, часто доступны всем процессам и могут попасть в логи.
  3. secrets в Compose, файл с доступом для каждого сервиса, смонтированный по пути /run/secrets/ИМЯ. Совет самого Docker: для паролей предпочитайте секреты. См. управление секретами.
mkdir -p secrets && chmod 700 secrets
openssl rand -base64 32 > secrets/db_password.txt
chmod 600 secrets/db_password.txt
touch app.env && chmod 600 app.env
printf '%s\n' app.env secrets >> .gitignore
printf '%s\n' app.env secrets >> .dockerignore

Команды создают закрытую папку со случайным паролем, ограничивают оба файла вашим пользователем и не пускают их в Git и в любой COPY . . из Dockerfile (.dockerignore исключает файлы из сборки). Ключи API кладите в app.env.

  • Образ PostgreSQL читает POSTGRES_PASSWORD_FILE; на странице образа суффикс _FILE поддерживается для четырёх переменных. Ваше приложение читает файл, только если это делает код. Попросите ассистента читать путь из DB_PASSWORD_FILE.
  • Следите за правами. Справочник сообщает, что uid, gid и mode для файловых источников игнорируются, поэтому читать файл смогут те, кому позволяют владелец и права файла на хосте. При «permission denied» смените владельца на числовой ID пользователя контейнера командой chown. Не делайте файл доступным всем.
  • Правка файла не меняет пароль в существующей базе. По странице образа, его переменные действуют только на пустой каталог данных. Пароль работающей базы меняйте в PostgreSQL, например командой psql \password, затем обновите файл.
  • Ключ уже утёк? Замените его: открытые .env и .git.

Не уверены, что открывают ваши контейнеры?

Бесплатная проверка в режиме только чтения покажет порты, доступные из интернета, включая опубликованные Docker, и ничего не изменит.

Получить бесплатную проверку сервера Связаться с инженером по безопасности

Шаг 4: ограничьте права контейнера приложения

Эти опции уменьшают ущерб, если приложение взломают. Ни одна не даёт гарантий, и каждая может сломать программу, не рассчитанную на неё, поэтому добавляйте по одной и запускайте приложение после каждой.

  • user: запускает процесс от обычного пользователя. По справочнику, если образ не задаёт пользователя, процесс идёт от root. Лучше задать USER в Dockerfile, как советуют рекомендации Docker по сборке; в примере Compose из README образа Node используется user: 'node'. Ограничение: файлы, которые пишет приложение, должны принадлежать этому пользователю. Базе user: не задавайте: на странице образа PostgreSQL сказано, что числовой пользователь вроде 1000:1000 не работает, если его нет в /etc/passwd.
  • read_only: true вместе с tmpfs даёт корневую файловую систему только для чтения и временное место в памяти; такое сочетание советует OWASP. Ограничение: приложение, пишущее загрузки, кэш или логи на свой диск, сломается, пока этим папкам не выдан том или tmpfs, а данные в tmpfs пропадают при остановке контейнера. База остаётся с записью: задокументированного рецепта read-only для её образа я не нашёл.
  • cap_drop: [ALL] убирает capabilities Linux. OWASP называет самым безопасным убрать все и вернуть только нужные. Ограничение: некоторым образам нужна одна обратно, например для порта ниже 1024. Прочитайте ошибку и верните только её через cap_add. У базы остаются стандартные настройки Docker: минимального набора для неё в документации я не нашёл.
  • no-new-privileges:true не даёт процессам контейнера получать новые привилегии, и OWASP советует включать его всегда. Ограничение: программы, опирающиеся на setuid внутри контейнера, сломаются.
  • mem_limit и pids_limit не дают зациклившемуся приложению задушить сервер. Ограничение: при слишком малых значениях обычная нагрузка убьёт контейнер. Начните с запаса. restart: unless-stopped поднимает упавший контейнер, но прячет цикл падений, поэтому смотрите логи.
  • Никогда не используйте privileged: true. Справочник Docker сообщает, что флаг даёт контейнеру все capabilities и доступ ко всем устройствам хоста. О флагах на стороне хоста читайте в руководстве по хосту, ссылка выше; идея всего этого описана в статье минимальные привилегии.

Healthcheck приложения использует wget и маршрут /health, поэтому и то и другое должно быть в вашем образе и коде.

Шаг 5: фиксируйте, сканируйте и безопасно обновляйте образы

Фиксация (pinning) означает указание точной версии, чтобы пересборка не могла тихо её изменить. В руководстве Docker по сборке сказано, что теги образов изменяемы, а фиксация по digest (хэшу содержимого) полностью защищает цепочку поставки. Формат образа в Compose: [<registry>/][<project>/]<image>[:<tag>|@<digest>].

  • Замените latest точным тегом вроде postgres:18.6-alpine: он был в списке на странице образа на момент написания. Зафиксируйте и строку FROM в Dockerfile.
  • Для жёсткой фиксации возьмите digest из docker images --digests и допишите его к тегу через знак at.
  • Сканируйте до деплоя. docker scout cves ОБРАЗ выводит известные уязвимости, а OWASP также называет Trivy и Grype. Нужен ли для Docker Scout аккаунт Docker, по документации подтвердить я не смог, поэтому уточните на его странице.

Обновляйтесь осознанно:

  1. Сначала сделайте дамп базы (шаг 6).
  2. Измените тег. Для базы смена мажорной версии требует отдельного плана, поэтому сначала прочитайте заметки о релизе.
  3. Выполните docker compose pull, затем docker compose up -d. Compose пересоздаёт изменившиеся контейнеры и сохраняет тома.
  4. Выполните docker compose ps и docker compose logs --tail 50 app. Ожидайте healthy и тихий лог.
  5. Для отката верните прежний тег и снова выполните docker compose up -d. Данные остаются в томе.

Обновляйте и хост, ведь контейнеры используют его ядро: управление патчами.

Шаг 6: данные, бэкапы, логи и что нельзя монтировать

  • Храните данные базы в именованном томе. По странице образа PostgreSQL 17 и старше хранит данные в /var/lib/postgresql/data, а 18 и новее использует /var/lib/postgresql. При неверном пути данные не сохранятся, когда контейнер пересоздаётся.
  • Знайте, что удаляет данные. docker compose down сохраняет именованные тома, а docker compose down -v их удаляет. Не запускайте вторую команду на проде по привычке.
  • Делайте дамп и проверяйте восстановление. Том не бэкап. Образ доверяет локальным соединениям через сокет внутри контейнера, поэтому пароль не спрашивается:
    mkdir -p backups && chmod 700 backups
    docker compose exec -T db pg_dump -U app appdb > backups/appdb-$(date +%F).sql
    -T отключает выделение терминала, которое не нужно при выводе в файл. Скопируйте дамп с сервера по правилу 3-2-1.
  • Ротируйте логи контейнеров. Драйвер json-file хранит логи без ограничения, пока не задан max-size, а max-file определяет, сколько ротированных файлов оставлять. Как читать логи: мониторинг сервера и анализ логов.

Что нельзя монтировать. В документации Docker сказано, что bind mount по умолчанию может писать на хост и это влияет на безопасность, поэтому добавляйте :ro, где приложению нужно только чтение.

  • /var/run/docker.sock. OWASP: это равносильно неограниченному root-доступу к хосту, а монтирование только для чтения не делает API только для чтения. См. сокет Docker.
  • /, /etc, /root или домашний каталог. Контейнер сможет их менять, включая SSH-ключи.
  • Папка проекта (.:/app). Она открывает .env и .git внутри контейнера. Встраивайте код в образ.

Усиленный compose-файл

Все шесть шагов в одном файле. Сохраните его как compose.yaml, создайте два файла из шага 3 и выполните docker compose up -d. Образ приложения собирается из вашего Dockerfile, в котором нужны зафиксированный FROM и USER node.

services:
  app:
    build: .
    command: ["node", "server.js"]         # боевой запуск
    ports:
      - "127.0.0.1:3000:3000"              # доступен только хосту (nginx)
    env_file:
      - ./app.env                          # ключи API, chmod 600, не в Git
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password   # этот файл читает ваш код
    secrets:
      - db_password
    user: node                             # не-root пользователь из образа Node
    read_only: true                        # корневая ФС только для чтения
    tmpfs:
      - /tmp                               # временное место в памяти
    cap_drop:
      - ALL                                # возвращайте только то, что просит ошибка
    security_opt:
      - no-new-privileges:true
    mem_limit: 512m
    pids_limit: 200
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:3000/health"]
      interval: 30s
      timeout: 5s
      retries: 3
    depends_on:
      db:
        condition: service_healthy         # ждать готовности базы
    networks: [frontend, backend]
    logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }

  db:
    image: postgres:18.6-alpine            # точный тег, блока "ports:" нет
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: appdb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - dbdata:/var/lib/postgresql         # PostgreSQL 18 и новее, см. шаг 6
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks: [backend]                    # только внутренняя сеть
    logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }

networks:
  frontend: {}
  backend:
    internal: true                         # нет выхода во внешний мир

volumes:
  dbdata:

secrets:
  db_password:
    file: ./secrets/db_password.txt

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

Проверьте результат изнутри и снаружи сервера

Не доверяйте файлу, проверяйте результат после каждого docker compose up -d.

  1. Посмотрите колонку портов.
    docker ps --format "table {{.Names}}\t{{.Ports}}"
    Иллюстративный вывод для усиленного файла:
    NAMES          PORTS
    myapp-app-1    127.0.0.1:3000->3000/tcp
    myapp-db-1     5432/tcp
    Пример вывода docker ps: два сервиса на 0.0.0.0 до правки, после неё один на 127.0.0.1, а база не опубликована
    Иллюстративный вывод: две подсвеченные строки это уязвимость, которую нужно устранить.
    У опубликованного порта есть адрес и стрелка, а 0.0.0.0 или [::] означает все интерфейсы. Одинокое 5432/tcp это лишь порт, объявленный образом: в справочнике Dockerfile сказано, что EXPOSE его не публикует.
  2. Посмотрите, что слушает хост. sudo ss -tlnp должен показывать 127.0.0.1:3000 и не показывать 5432.
  3. Проверьте с другой машины и только свой сервер, заменив адрес из документации на свой:
    nc -zv -w 3 203.0.113.10 3000
    nc -zv -w 3 203.0.113.10 5432
    nc -zv -w 3 203.0.113.10 443
    -z проверяет наличие слушателя, ничего не отправляя, -v печатает результат, -w 3 прекращает попытку через три секунды. 3000 и 5432 должны не отвечать, 443 должен ответить. Никогда не сканируйте чужие серверы и те, которые вам не разрешено проверять.

Если закрытый порт отвечает, вероятно, вы поправили файл, но не пересоздали контейнер, поэтому выполните docker compose up -d снова. Если отвечает по-прежнему, поищите файл переопределения или контейнер, запущенный вручную через docker run -p.

Весь чек-лист на одном экране

  • У базы нет ports:; приложение на 127.0.0.1 за nginx с HTTPS.
  • База во внутренней сети internal: true с healthcheck.
  • В compose-файле нет паролей; app.env и файлы секретов с правами 600, исключены из Git и сборок.
  • Не-root пользователь и no-new-privileges; read_only и cap_drop: [ALL], где приложение это выдерживает.
  • Данные в именованном томе, дампы вне сервера, ротация логов, ничего чувствительного не смонтировано.
  • После каждого изменения проверка через docker ps, ss -tlnp и nc снаружи.

Что делать дальше: продолжите с чек-листа безопасности VPS (SSH, файрвол, обновления). Если сервер уже работал с файлом «до», считайте пароль базы и ключи из него скомпрометированными, замените их и прочитайте, как обнаружить взлом сервера и ответить на него.

Ещё для вайб-кодеров: все руководства серии собраны в центре безопасности для вайб-кодеров, а пункты можно отмечать в интерактивном чек-листе.

Часто задаваемые вопросы

Защищает ли ufw контейнеры Docker на моём VPS?

Для опубликованных портов нет. В документации Docker сказано, что трафик контейнеров отводится раньше правил ufw, поэтому порт, опубликованный через ports:, остаётся доступным, даже если ufw его запрещает. Привязывайте закрытые сервисы к 127.0.0.1 или ничего не публикуйте и проверяйте снаружи сервера.

Чем ports отличается от expose в Docker Compose?

ports публикует порт контейнера на хосте, на всех интерфейсах, если адрес не указан. expose только объявляет порт для других контейнеров и ничего не публикует. Контейнеры одной сети Compose и так видят друг друга по имени сервиса.

Как оставить PostgreSQL в Docker Compose закрытым?

Не задавайте сервису базы запись ports, подключите его к сети с internal: true и пускайте в эту сеть только приложение. Приложение обращается к db:5432, а из интернета до базы не достать.

Безопасны ли переменные окружения для секретов в Docker Compose?

Это более слабый вариант. Docker не советует передавать пароли переменными окружения: их видят многие процессы, и они могут попасть в логи. Используйте секреты Compose там, где программа умеет читать файл, и защищённый env_file (права 600, не в Git), где не умеет.

Стоит ли использовать тег latest на проде?

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

Почему контейнер перестал работать после read_only или cap_drop?

Скорее всего, приложение пишет на диск или нуждается в убранной capability. Прочитайте ошибку, выдайте нужной папке том или tmpfs либо верните через cap_add только одну названную capability. Добавляйте усиление по одной опции.

Как обновить приложение на Docker Compose, не потеряв данные?

Сделайте дамп базы, измените тег образа, затем выполните docker compose pull и docker compose up -d. Compose пересоздаёт изменившиеся контейнеры и сохраняет тома. Никогда не запускайте docker compose down -v на проде: эта команда удаляет именованные тома.

Не уверены, в каком состоянии ваш сервер?

Получите бесплатную проверку безопасности в режиме только чтения или поговорите с инженером по безопасности об управляемой защите.

Получить бесплатную проверку сервера Связаться с инженером по безопасности