На панели App Platform есть кнопка Deploy, и со стороны кажется, что она запускает одно приложение. На деле за ней часто стоит маленький оркестр: веб-процесс, который отвечает на запросы, фоновый работник, который разбирает очередь, и задание по расписанию, которое раз в сутки что-то пересчитывает или чистит. Платформа собирала их вместе, следила за ними и перезапускала, а вы видели один зелёный индикатор. Когда вы переезжаете на обычный VPS, этот индикатор исчезает, и каждую роль приходится назвать по имени и поселить отдельно.

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

Три процесса под одной кнопкой

Первое, что стоит сделать, — выписать, из каких компонентов состоит приложение в панели. Нам нужны не красивые названия, а ответы на три вопроса по каждому компоненту: какая команда его запускает, на какие события он реагирует и что ему нужно снаружи.

Роли условного приложения до и после переезда
РольЧто делаетКак живёт на VPS
Веб-процессПринимает HTTP-запросы, принимает загрузкиКонтейнер за обратным прокси, порт наружу не опубликован
Фоновый работникБерёт задачи из очереди, обрабатывает файлыОтдельный контейнер из того же образа, другая команда запуска
Задание по расписаниюРаз в сутки считает сводку, чистит записиКороткоживущий запуск по таймеру или cron на хосте

Обратите внимание: веб и работник часто собираются из одного кода и даже из одного образа, а различаются только командой. Это удобно. Вы собираете образ один раз и запускаете его в разных ролях. Задание по расписанию устроено иначе: оно не должно висеть постоянно, ему нужно стартовать, сделать работу и завершиться. Если запустить его как обычный долгоживущий сервис с политикой перезапуска, оно будет бесконечно стартовать заново после каждого успешного завершения. Это классическая ошибка при переносе: расписание превращается в бесконечный цикл.

Как описать это в Compose

Для одного VPS естественный инструмент — Compose-файл. Документация Docker отмечает, что одну и ту же конфигурацию можно использовать в CI, staging и production, меняя настройки под окружение, а для production, как правило, нужно убрать bind mount с кодом, поменять порты и переменные окружения и добавить политику перезапуска (Docker Compose: Production). Для нашего приложения это означает, что код живёт в образе, а не монтируется с диска разработчика, и что у долгоживущих сервисов есть явная политика перезапуска.

services:
  web:
    image: registry.example.com/shop-app:2026-11-01
    command: ["./run-web"]
    restart: unless-stopped
    env_file: /etc/shop-app/app.env
    expose:
      - "8080"
    depends_on:
      - redis

  worker:
    image: registry.example.com/shop-app:2026-11-01
    command: ["./run-worker"]
    restart: unless-stopped
    env_file: /etc/shop-app/app.env
    depends_on:
      - redis

  redis:
    image: redis:7
    restart: unless-stopped

Имена образа, команд и версий здесь условные: подставьте свои. Важна структура. Веб и работник используют один образ, но разные команды. Порт у веба объявлен через expose, то есть он доступен другим контейнерам в сети Compose, но не опубликован на внешний интерфейс сервера. Наружу будет смотреть только прокси, о нём ниже. Задания по расписанию в этом файле нет намеренно, и сейчас объясню почему.

Расписание: два честных варианта

Ежедневное задание можно запускать двумя способами, и оба рабочие.

  • Cron или systemd timer на хосте вызывает одноразовый запуск контейнера: docker compose run --rm с нужной командой. Расписание живёт в системе, контейнер создаётся на время работы и удаляется.
  • Отдельный сервис-планировщик в самом Compose, который сам знает время и дёргает нужную команду. Это ещё один процесс, который нужно мониторить, зато расписание лежит рядом с остальной конфигурацией.

Для небольшого приложения проще первый вариант. Системный таймер умеет то, что на платформе вы получали по умолчанию, но теперь должны включить сами: записывать в журнал каждый запуск, не стартовать повторно, если предыдущий ещё идёт, и догонять пропущенный запуск после перезагрузки сервера. Последнее особенно важно. Если VPS был перезагружен ровно в момент, когда задание должно было сработать, обычный cron просто пропустит этот день, а таймер systemd с настройкой догоняющего запуска выполнит его при старте. Решите заранее, какое поведение вам нужно: для ежедневной сводки пропуск может быть безобиден, а для списания остатков или рассылки — нет.

И ещё одна деталь про одноразовые запуски. Задание должно быть идемпотентным настолько, насколько это возможно: если оно случайно сработает дважды (скажем, вы запустили его вручную при проверке, а потом пришёл таймер), повторный запуск не должен ломать данные. Это свойство самого задания, не инфраструктуры, но при переезде оно впервые проверяется на практике: на платформе дублирующие запуски исключала её собственная логика, а теперь за это отвечаете вы.

Путь одного файла: где он лежит на самом деле

Теперь самая опасная часть. Пользователь загружает документ. Веб-процесс принял его, положил куда-то и поставил в очередь задачу на обработку. Работник взял задачу, нашёл файл, преобразовал и сохранил результат. Вопрос в том, что значит «куда-то» и «нашёл».

Документация App Platform описывает это однозначно: локальная файловая система экземпляра эфемерна и теряется после redeploy или замены контейнера, а у разных экземпляров приложения файловые системы раздельные. Для постоянных данных предлагаются object storage или managed database, а volumes платформа на дату проверки не поддерживает (DigitalOcean App Platform: Store data). Из этого следует важное практическое наблюдение: если приложение работало на платформе корректно, то оно либо уже хранит загрузки во внешнем хранилище, либо работало «почти правильно», пока не случился redeploy. Поэтому первый шаг при переезде — не копировать каталог, а выяснить, что в коде делает функция сохранения файла.

Три возможных ситуации

  1. Код пишет загрузки в object storage. Тогда на VPS ничего не меняется: те же ключи доступа, тот же бакет. Переезд затрагивает только процессы. Это самый спокойный вариант, и файлы вообще не двигаются.
  2. Код пишет в локальный каталог контейнера, а данные считались временными. Например, это промежуточные файлы, которые работник удаляет после обработки. Тогда на VPS их можно оставить в временном каталоге, но помните про второй пункт ниже.
  3. Код пишет в локальный каталог, а данные на самом деле нужны постоянно. Это скрытая ошибка, которую переезд просто обнажает. На платформе такие файлы терялись при замене контейнера, и, возможно, пользователи уже сталкивались с исчезнувшими загрузками. Прежде чем переносить, решите, куда эти данные должны идти теперь.

Почему локальный каталог контейнера на VPS тоже не хранилище

Соблазн понятен: на VPS у вас есть настоящий диск, значит, можно писать куда угодно. Но каталог внутри контейнера всё равно исчезает, когда контейнер пересоздаётся, а по документации Compose это обычная ситуация: обновление кода в образе требует пересборки образа и пересоздания контейнера, при этом отдельный сервис можно обновить, не трогая его зависимости (Docker Compose: Production). Каждый ваш релиз веб-сервиса — это новый контейнер. Если загрузки лежали в его собственной файловой системе, они уйдут вместе со старым контейнером.

Поэтому на VPS у вас два нормальных пути для постоянных файлов. Первый — смонтировать том или каталог хоста в контейнеры, которым нужен доступ. Второй — оставить внешний object storage, как и было. Выбор зависит от того, что вам важнее. Локальный том проще и быстрее, но привязывает данные к одному серверу: если веб и работник живут на одной машине, они увидят один и тот же каталог, а если вы когда-нибудь захотите вынести работника на другой сервер, общий каталог придётся заменить на что-то сетевое. Object storage избавляет от этой привязки ценой дополнительной зависимости и настройки доступа.

Для нашего примера на одном VPS разумно сделать так: загрузки пользователей и результаты обработки лежат в смонтированном каталоге хоста, который веб и работник видят по одинаковому пути. Тогда веб-процесс сохраняет файл, работник находит его по тому же пути, а пересоздание любого контейнера данные не затрагивает. Но это решение должно сопровождаться резервным копированием каталога: том переживает пересоздание контейнера, но не переживает потерю диска или ошибочное удаление.

  web:
    volumes:
      - /srv/shop-app/uploads:/data/uploads
  worker:
    volumes:
      - /srv/shop-app/uploads:/data/uploads

Одинаковый путь внутри обоих контейнеров — не прихоть. Если веб записал в очередь абсолютный путь к файлу, работник должен открыть именно его. На платформе с несколькими экземплярами такая схема сломалась бы сразу, потому что у каждого экземпляра своя файловая система. Если у вас в коде когда-то были костыли вроде «передать файл в теле задачи» из-за этого ограничения, на VPS их можно упростить, но только осознанно и после проверки.

Права доступа: незаметная причина «файл не найден»

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Кто теперь следит, чтобы всё жило

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

Первый уровень — политика перезапуска контейнера. Документация Compose прямо называет её одной из типичных production-настроек (Docker Compose: Production). Вариант unless-stopped поднимет контейнер после сбоя и после перезагрузки сервера, если вы сами его не останавливали. Второй уровень — сам демон контейнеров: он должен стартовать при загрузке системы, иначе политика перезапуска не сработает, потому что некому её исполнять. Это проверяется одной командой управления службами и один раз включается. Третий уровень — наблюдение: перезапуск скрывает сбой, поэтому контейнер, который падает каждые десять секунд, выглядит «живым». Нужен хотя бы простой способ увидеть счётчик перезапусков и журналы.

Заметим, чего на VPS нет само по себе. Платформа могла масштабировать приложение на несколько экземпляров, управляла сертификатами и держала резервирование. Один VPS этого не воспроизводит: если упадёт сам сервер, упадёт всё приложение, а сертификаты и их продление придётся настраивать. Это не недостаток, а условия сделки: вы получаете предсказуемую машину и полный контроль, но резервирование и автоматику добавляете сами или сознательно обходитесь без них.

Секреты и переменные окружения

В панели платформы вы хранили ключи доступа, адреса баз данных и токены в настройках приложения. На VPS они переезжают в файл окружения или другой механизм хранения. В примере выше это /etc/shop-app/app.env. Несколько практических правил, которые вытекают из самой ситуации:

  • Файл с секретами не лежит в репозитории и не попадает в образ. Образ должен быть пригоден к запуску с любым набором значений.
  • Права на файл ограничены: читать его должен только тот, кто запускает контейнеры.
  • Список переменных стоит выписать из панели до переезда целиком. Забытая переменная проявится не сразу, а, например, когда ежедневное задание впервые попытается отправить сводку.
  • Значения, которые на платформе подставлялись автоматически (например, адрес подключённой базы), на VPS придётся задать явно. Сверьте, какие переменные платформа создавала сама.

Ежедневное задание использует тот же файл окружения, что и остальные. Если запускать его с хоста через таймер, убедитесь, что оно получает те же переменные. Типичная ошибка: из терминала всё работает, а из таймера нет, потому что окружение у него другое.

Сетевая схема: одна дверь вместо россыпи адресов

На платформе у приложения был публичный адрес и, возможно, внутренние адреса компонентов. На VPS схему лучше сделать максимально простой, чтобы её можно было нарисовать в одну строку: интернет приходит на обратный прокси, прокси передаёт запросы веб-контейнеру, а веб, работник и очередь общаются между собой внутри сети Compose.

  1. Наружу открыты только порты 80 и 443 (и порт управления для вашего доступа к серверу). Веб-контейнер, очередь и база не публикуют порты на внешний интерфейс.
  2. Прокси завершает TLS. На платформе сертификатом занималась она; теперь это обязанность прокси и его настройки автоматического продления. Сертификат, который тихо истёк, снаружи выглядит как полное падение сайта.
  3. Внутренние имена сервисов (web, redis) заменяют внутренние адреса платформы. Проверьте, что в конфигурации приложения нет жёстко прописанных старых адресов.
  4. Базу данных решите отдельно. Если она была управляемой и остаётся на старом месте, вам нужно разрешить обращение с нового сервера и подумать о задержке между площадками. Если вы переносите её на VPS, это самостоятельная задача со своим планом.

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

Сам сервер — это ресурс, который вы подбираете под нагрузку. В публичном предложении VPS MAATRIX указаны NVMe-диски, процессоры AMD EPYC и root-доступ, а тарифы различаются по vCPU, памяти и диску: от Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe до Djed с 16 vCPU, 32 ГБ и 320 ГБ. Эти цифры описывают ресурсы, а не гарантированную ёмкость: сколько загрузок в минуту выдержит ваш работник, покажет только замер на вашем приложении. Для нашего примера из трёх процессов и очереди важны две вещи: память (обработка файлов может быть прожорливой) и диск (загрузки копятся). Оба параметра проще увеличить заранее, чем разбирать последствия нехватки. Локации в предложении перечислены: UK, US, RU и FR; точная доступность образов и операционных систем проверяется в форме заказа.

Репетиция: как доказать, что переезд сработал

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

  1. Сквозная загрузка. Загрузите через сайт тестовый файл. Убедитесь, что он появился в каталоге хранения, что работник взял задачу, обработал и записал результат, и что пользователь видит итог.
  2. Пересоздание контейнеров. Остановите и создайте заново веб и работник (как при релизе). Загруженный файл и результат обработки должны остаться на месте. Если они исчезли, значит, где-то остался локальный каталог контейнера, выданный за хранилище.
  3. Перезагрузка сервера. Перезапустите VPS целиком. Проверьте, что демон контейнеров поднялся, сервисы встали сами, сертификат отдаётся, очередь не потеряла задачи (если это важно для вашего приложения).
  4. Аварийный сбой процесса. Принудительно завершите работника. Он должен вернуться, а задача, которая была в обработке, — либо продолжиться, либо корректно повториться. Что именно произойдёт, зависит от вашей очереди, и это нужно знать заранее.
  5. Задание по расписанию. Запустите его вручную и проверьте результат и журнал. Затем убедитесь, что таймер зарегистрирован и показывает ближайший запуск. Отдельно проверьте, что повторный ручной запуск не портит данные.
  6. Резервная копия и восстановление. Скопируйте каталог загрузок и данные базы, затем попробуйте восстановить их на чистом окружении. Копия, из которой ни разу не восстанавливались, остаётся предположением.

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

Если вам нужен общий порядок переезда самого сервера, а не конкретно App Platform, есть отдельный разбор: как перенести сайт и сервер с DigitalOcean на свой VPS. В этой статье мы разобрали именно то, что превращает приложение на платформе в набор ролей на обычной машине.

Что было кнопкой, а стало задачей

Переезд полезно читать как перенос ответственности. Кнопка Deploy раньше решала сразу несколько вопросов: где запускать процессы, кто их перезапускает, где лежат файлы, как выдаётся сертификат, откуда приходят секреты. Теперь каждый из них имеет конкретное место на вашем сервере: Compose-файл, политика перезапуска, том или object storage, прокси с продлением сертификата, файл окружения с ограниченными правами.

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