Java-приложение разворачивается как один исполняемый jar. От вас серверу нужно четыре вещи: версия Java не ниже той, под которую вы компилировали, jar, который несёт в себе все свои зависимости, код, слушающий выданный серверу порт, и потребление памяти, которое укладывается в лимит тарифа с запасом. Всё остальное - переменные окружения, домен, логи, деплой из GitHub - надстраивается над этими четырьмя пунктами. Если они в порядке, достаточно java -jar app.jar. Если хотя бы один нарушен, вы увидите ошибку, которая редко называет настоящую причину.
В этой статье мы проходим весь путь на хостинговом Java-сервере: какую версию Java выбрать, как собрать fat jar через Maven или Gradle для Spring Boot и для всего остального, как порт попадает в ваш код, как heap JVM соотносится с лимитом контейнера, как передать настройки, как доставить сборки из GitHub на сервер без компиляции на нём и как читать и хранить логи. Там, где Java-линейка RE:NODE делает что-то особенное, мы об этом говорим; остальное подходит любому хостингу, который запускает jar в контейнере. Про особенности Spring в продакшене - HikariCP, Actuator, proxy-заголовки - есть отдельная статья, деплой Spring Boot в продакшен.
Как Java-сервер запускает ваш jar#
Хостинговый Java-сервер - это контейнер с JDK внутри и фиксированной командой запуска. На Java-линейке RE:NODE эта команда выглядит целиком так:
java -XX:MaxRAMPercentage=80.0 -Dterminal.jline=false -Dterminal.ansi=true \ -Dserver.port=${SERVER_PORT} -jar {{JARFILE}}Из неё следуют три вещи, и от них зависит каждый раздел ниже.
Во-первых, шага сборки нет. Сервер запускает jar; он не запускает Maven или Gradle. Вы собираете у себя на машине или в CI, и на сервер попадает только готовый артефакт.
Во-вторых, имя jar задаётся переменной JARFILE, по умолчанию это app.jar, а путь отсчитывается от папки сервера. В нём может быть подпапка, так что build/app.jar или release/service.jar подойдут, если имя заканчивается на .jar. Менять её нужно на вкладке Startup.
В-третьих, heap и порт определяет команда, а не вы. Верхняя граница heap - 80% памяти тарифа, а порт передаётся как системное свойство server.port. Spring Boot читает это свойство сам; другим фреймворкам нужна одна строка кода, о ней в разделе про порт.
Саму команду запуска клиент не редактирует - только её переменные. Это сделано намеренно: так запуск сервера остаётся предсказуемым. Из этого же следует, что привычные приёмы вроде добавления -Xmx или -XX:+ExitOnOutOfMemoryError в командную строку недоступны. Для небольших тарифов это важно, и мы разберём это в разделе про память.
Только что созданный Java-сервер не пуст: на нём лежит небольшая рабочая страница на его собственном адресе, собранная как app.jar, чтобы первый запуск показал что-то полезное, а не упал. Эта страница - просто jar под именем по умолчанию. Загрузите или задеплойте свой app.jar, и он заменит страницу; ваши файлы она никогда не перезаписывает.
Панель решает, что сервер закончил запуск, когда строка консоли содержит одно из нескольких слов: Started, Listening, Ready или Server running (в любом регистре). Строка Spring Boot Started Application in 4.2 seconds подходит. Обычный jar, который при старте ничего не печатает, будет висеть в панели в статусе "Starting", хотя работает отлично. Выведите строку вроде Listening on 0.0.0.0:<port>, когда ваш сервер начнёт принимать соединения, и статус будет верным.
Выбор между Java 17, 21 и 25#
В Java-линейке три образа: Java 17, Java 21 и Java 25, все - релизы с долгосрочной поддержкой. Выбрать нужный можно в списке Docker Image на вкладке Startup или настройкой версии рантайма на вкладке GitHub, если сервер деплоится из репозитория - смена там пересоздаёт сервер на новом образе.
Правило простое: рантайм должен быть той же мажорной версии, что и целевая версия компиляции, или новее. Jar, скомпилированный под 17, без изменений работает на 21 и 25. Jar, скомпилированный под 21, не запустится на 17 и выдаст UnsupportedClassVersionError, причём в ошибке указана версия формата class-файла, а не версия Java:
| Версия формата class-файла | Релиз Java | Типичная причина |
|---|---|---|
| 61.0 | Java 17 | Сборка с --release 17 |
| 65.0 | Java 21 | Сборка на JDK 21 без флага release |
| 69.0 | Java 25 | Сборка на JDK 25 без флага release |
Фиксируйте целевую версию в самой сборке, а не надейтесь на то, какой JDK окажется на машине сборки. В Maven это <maven.compiler.release>21</maven.compiler.release> в <properties>; в Gradle - toolchain:
java { toolchain { languageVersion = JavaLanguageVersion.of(21) }}Что выбрать: Spring Boot 3 требует 17 или новее, как и актуальные релизы Quarkus и Micronaut. Если ваши зависимости свежие, разумный выбор по умолчанию - 21: виртуальные потоки там стабильны, а поддержка продлится ещё годы. Берите 25, если хотите самую новую LTS и ваши библиотеки уже подтянулись; сначала прогоните на ней тесты, потому что на новом релизе чаще всего ломаются инструменты на базе агентов и библиотеки со старым байткодом. Оставляйте 17 для кода, который перенести нельзя, например сервиса на Spring Boot 2.7, и считайте это миграцией, которую вы себе должны.
Сборка jar, который запускается сам по себе#
"Исполняемый" значит две вещи: в манифесте указан Main-Class, а все зависимости лежат внутри jar или рядом с ним. Jar, собранный обычным mvn package, не имеет ни того ни другого и падает с no main manifest attribute, in app.jar или с NoClassDefFoundError на первом же классе библиотеки.
Для Spring Boot оба пункта выполняет плагин Boot. ./mvnw package или ./gradlew bootJar создаёт исполняемый fat jar с встроенным веб-сервером. Задайте ему фиксированное имя, чтобы JARFILE на сервере не приходилось менять между версиями:
<build> <finalName>app</finalName> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins></build>Результат - target/app.jar. Maven также оставляет target/app.jar.original - тонкий jar до репакетирования; его можно игнорировать. В Gradle bootJar пишет в build/libs/, а задача jar рядом создаёт второй, обычный jar с суффиксом -plain. Загружайте тот, что без суффикса, или задайте archiveFileName = "app.jar" в задаче bootJar.
У других фреймворков свой путь к тому же результату:
- Quarkus по умолчанию собирает "fast jar":
target/quarkus-app/quarkus-run.jarи рядом каталогlib. Он работает, если загрузить всю папкуquarkus-appи указать вJARFILEзначениеquarkus-app/quarkus-run.jar. Если нужен один файл, задайтеquarkus.package.jar.type=uber-jar(в старых релизах 3.x это былоquarkus.package.type), и получите один*-runner.jar. - Ktor с его плагином для Gradle имеет задачу
buildFatJar, которая пишет-all.jarвbuild/libs/. - Проекты Micronaut, созданные в Micronaut Launch, включают плагин Shadow для Gradle, так что
./gradlew shadowJarдаёт-all.jar; в Mavenmvn packageсоздаёт исполняемый jar с зависимостями. - Обычная Java, Discord-бот на JDA, сервис на Javalin: используйте Maven Shade plugin или Gradle Shadow plugin и укажите главный класс в его настройках.
Проверьте результат до того, как он покинет вашу машину. java -jar target/app.jar должен запускаться без чего-либо ещё в classpath, а unzip -p target/app.jar META-INF/MANIFEST.MF показывает строку Main-Class. Если jar локально стартует простым java -jar на той же мажорной версии, что вы выбрали для сервера, он стартует и там.
Порт: SERVER_PORT и server.port#
К вашему тарифу прилагается выделение - адрес и порт, - и приложение должно слушать именно этот порт на всех интерфейсах. Не 8080 и не localhost. Сервис на неверном порту запускается без ошибок, пишет, что готов, и никому не отвечает.
Порт доходит до вашего кода дважды. Он есть в окружении как SERVER_PORT, а команда запуска передаёт его как системное свойство server.port. Именно вторая форма избавляет Spring Boot от какой-либо настройки: системные свойства Java в порядке приоритета Spring выше application.properties, поэтому любой server.port, заданный внутри jar, переопределяется настоящим. Это удобно, но иногда удивляет, если вы ждали, что победит ваше значение.
Остальные фреймворки читают порт каждый по-своему:
| Фреймворк | Куда прописывается порт |
|---|---|
| Spring Boot | Ничего делать не нужно, server.port уже задан |
| Quarkus | quarkus.http.port=${SERVER_PORT:8080} |
| Micronaut | micronaut.server.port: ${SERVER_PORT:8080} |
| Ktor | System.getenv("SERVER_PORT") |
| Обычная Java | System.getenv("SERVER_PORT") |
Quarkus также нужен quarkus.http.host=0.0.0.0, если вы его меняли, а самописный сервер должен привязываться ко всем интерфейсам, а не к адресу loopback. Для страницы проверки состояния или небольшого API хватает встроенного в JDK HTTP-сервера, и он показывает весь шаблон целиком:
import com.sun.net.httpserver.HttpServer;import java.net.InetSocketAddress;public class Main { public static void main(String[] args) throws Exception { int port = Integer.parseInt(System.getenv().getOrDefault("SERVER_PORT", "8080")); HttpServer server = HttpServer.create(new InetSocketAddress("0.0.0.0", port), 0); server.createContext("/", exchange -> { byte[] body = "ok\n".getBytes(); exchange.sendResponseHeaders(200, body.length); exchange.getResponseBody().write(body); exchange.close(); }); server.start(); System.out.println("Listening on 0.0.0.0:" + port); }}Discord-боту на JDA или любому воркеру, который только устанавливает исходящие соединения, слушать порт не нужно вовсе. Но ему всё равно нужна строка запуска, которую узнаёт панель, так что выведите Ready, когда бот подключится.
Для домена и HTTPS ставьте перед приложением слот прокси, а не завершайте TLS внутри JVM. В тарифы для приложений входит слот обратного прокси: направьте A-запись на показанный адрес, и сертификат будет выпущен и продлеваться автоматически. Приложение продолжает говорить по обычному HTTP на своём порту, а настоящий адрес клиента приходит в X-Forwarded-For. В Spring Boot параметр server.forward-headers-strategy=framework заставляет request.getRemoteAddr() и генерируемые URL учитывать его. Про DNS-часть рассказывает статья ваш домен и его сертификат.
Память: heap, остальная часть JVM и лимит#
Именно здесь чаще всего ошибаются при развёртывании Java на небольших тарифах, и именно здесь пригодится знание команды запуска.
У контейнера жёсткий лимит памяти, равный тарифу. Потребление JVM - это heap плюс всё, что не heap: metaspace для загруженных классов, кэш JIT-кода, по одному стеку на поток, прямые байтовые буферы Netty и NIO и собственные структуры сборщика мусора. Веб-приложение на Spring Boot обычно несёт пару сотен мегабайт такого до того, как обслужит первый запрос.
-XX:MaxRAMPercentage=80.0 позволяет heap расти до 80% лимита контейнера. JVM берёт не всё сразу - heap начинается меньше и растёт, когда приложение выделяет память быстрее, чем сборщик её освобождает, - но под нагрузкой ему разрешено дойти до потолка, а память вне heap должна уместиться в остаток:
| Память тарифа | Потолок heap | Остаётся на всё остальное |
|---|---|---|
| 1 GB | около 820 MB | около 205 MB |
| 2 GB | около 1,6 GB | около 410 MB |
| 4 GB | около 3,3 GB | около 820 MB |
| 8 GB | около 6,6 GB | около 1,6 GB |
На 2 GB и выше этого достаточно для типичного сервиса. На 1 GB для Spring Boot тесно: 205 MB на metaspace, кэш кода и 200 потоков Tomcat оставляют мало места, а heap, заполнившийся при всплеске, может вытолкнуть общий объём за черту. Небольшой API на Javalin, бот на JDA или сервис на Quarkus спокойно помещаются в 1 GB. Приложению на Spring Boot с JPA комфортнее на 2 GB.
Важно, что происходит на пределе. В RE:NODE ядро останавливает контейнер, и он чисто перезапускается, а не уходит в swap. Поэтому возможны два разных сбоя, и лечатся они противоположными способами:
- `java.lang.OutOfMemoryError: Java heap space` в консоли. Потолок heap достигнут, и JVM сама об этом сообщила. Данные не помещаются или что-то течёт. Решение - тариф побольше или меньше данных в памяти.
- Перезапуск вообще без ошибки Java. Весь процесс пересёк лимит контейнера, и его остановили снаружи; у JVM не было возможности что-либо записать в лог. Дело не в одном только heap - виновата память вне heap.
Поскольку командная строка фиксирована, а переменные, начинающиеся с JAVA, JDK_ или JVM, из панели задать нельзя, уменьшить heap через JAVA_TOOL_OPTIONS не получится. Зато вы можете управлять той частью, что вне heap, - обычно именно на ней и срываются тарифы на 1 GB:
# Fewer request threads: each one has its own stackserver.tomcat.threads.max=50# Create beans when first used instead of all at startspring.main.lazy-initialization=true# Do not keep JPA sessions open through the view layerspring.jpa.open-in-view=falseЛенивая инициализация снижает расход памяти и время старта ценой более медленных первых запросов и ошибок, которые проявляются позже, а не при загрузке; проверьте её, прежде чем полагаться. На Java 21 и 25 параметр spring.threads.virtual.enabled=true заменяет платформенные потоки запросов виртуальными, что резко сокращает память под стеки для сервисов с преобладанием ввода-вывода. Помимо этого, уберите ненужные зависимости - каждый starter добавляет классы в metaspace. Если вы всё измерили и вам всё равно нужна другая настройка heap, напишите нам в тикет, а не воюйте с лимитом; а если приложению действительно нужно больше памяти, честный ответ - следующий тариф. Как читать графики перед этим, описано в статье когда пора переходить на другой тариф.
За перезапусками следят. Сторож сбоев опрашивает сервер каждые две минуты; перезапуски, которые вы запросили сами, не считаются, но три незапланированных за час выводят предупреждение на странице сервера и открывают тикет, а шесть приостанавливают сервер. Jar, который убивают на лимите каждые несколько минут, - ровно то, что этот механизм ищет, так что исправьте размер до того, как оставлять сервис работать. Консоль показывает график памяти относительно лимита; понаблюдайте за ним в течение дня реальной нагрузки. Общий фон - в статье swap в Linux и OOM killer.
Переменные окружения и секреты#
Настройки, которые меняются между окружениями - URL базы данных, API-ключи, имя профиля, - должны лежать в окружении, а не в jar. Relaxed binding в Spring Boot превращает SPRING_DATASOURCE_URL в spring.datasource.url, SPRING_PROFILES_ACTIVE в spring.profiles.active и так далее, так что большинству настроек код не нужен.
В RE:NODE свои переменные добавляются на вкладке Variables сервера, в разделе "Your variables": имя и значение, которые попадают в контейнер при следующем запуске, до 25 на сервер. Имена состоят из заглавных букв, цифр и подчёркиваний и начинаются с буквы. Имена, которыми уже владеют панель, демон или рантайм языка, отклоняются, причём список шире, чем можно подумать: всё, что начинается с SERVER_, JAVA, JDK_, JVM, APP_, DB_, DATABASE_, PG, POSTGRES, REDIS или AWS_, а также такие имена, как PATH и CLASSPATH. Поэтому DB_PASSWORD не сохранится, а SPRING_DATASOURCE_PASSWORD или ORDERS_DB_PASSWORD сохранятся.
SPRING_PROFILES_ACTIVE=prodSPRING_DATASOURCE_URL=jdbc:postgresql://db-host:port/appdbSPRING_DATASOURCE_USERNAME=appuserSPRING_DATASOURCE_PASSWORD=change-meDISCORD_BOT_TOKEN=change-meХост и порт выше - заглушки; используйте те, что показывает ваш сервер базы данных. Вне Spring читайте переменные через System.getenv("NAME") и падайте сразу при старте, если нужной нет: понятная ошибка в первую секунду лучше, чем NullPointerException через час.
Если вам удобнее файл, Spring Boot также читает application.properties или application.yml, лежащие рядом с jar или в папке config/ рядом с ним, и они приоритетнее копии внутри jar. Храните такой файл на сервере и никогда в репозитории. Общие правила - что считать секретом, ротация, как не пускать .env в Git - описаны в статье переменные окружения и секреты.
В тарифы для приложений входят два слота базы данных, создаваемых в панели, со сгенерированными хостом, пользователем и паролем, что подходит небольшому сервису. Для PostgreSQL, MongoDB или MySQL на отдельном сервере хостинг баз данных даёт каждому движку свой тариф и свои учётные данные.
Деплой из GitHub без сборки на сервере#
Загрузка jar вручную через файловый менеджер или SFTP подходит для первого деплоя. Для всего, что вы обновляете часто, подключите GitHub.
Вкладка GitHub связывает репозиторий через GitHub App с короткоживущими токенами, поэтому приватные репозитории работают. Вы выбираете production-ветку и папку для кода, а также два переключателя: забирать последний коммит при каждом запуске и деплоить при push, что перезапускает работающий сервер и запускает остановленный. Каждый деплой записывается.
Загвоздка та же, что и в команде запуска: сервер запускает jar, а не собирает его. Репозиторий с исходниками Java в production-ветке не даёт серверу ничего для запуска. Чистый подход - поручить GitHub Actions собрать jar и опубликовать его в ветку, где нет ничего, кроме артефакта, и направить сервер на эту ветку.
name: Build jaron: push: branches: [main]permissions: contents: writejobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: distribution: temurin java-version: '21' cache: maven - run: ./mvnw -B -DskipTests package - name: Publish the jar to the deploy branch run: | mkdir out && cp target/app.jar out/app.jar cd out git init -b deploy git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add app.jar git commit -m "Build ${GITHUB_SHA::7}" git push --force \ "https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" \ deployЕсли хотите, чтобы упавший тест останавливал деплой, запустите тесты отдельным шагом перед package; -DskipTests здесь нужен лишь для краткости примера. Force-push свежей ветки из одного коммита сделан намеренно: если на каждой сборке коммитить jar поверх предыдущего, репозиторий будет расти на размер jar каждый раз, а такая история никому не нужна - история исходников лежит в main.
На сервере установите production-ветку deploy, включите деплой при push и оставьте JARFILE равным app.jar. Версию Java в сборке и образ сервера выставляйте на одну и ту же мажорную версию. Одно предостережение про настройку папки: всё, что лежит в папке с кодом, заменяется репозиторием при каждом запуске. Если приложение пишет загрузки, файл H2 или SQLite либо логи, пусть пишет за пределами этой папки - или положите код в подпапку, например release, и укажите в JARFILE значение release/app.jar, чтобы корень сервера остался вашим.
На вкладке GitHub есть и предварительная проверка, которая читает репозиторий до того, как вы его свяжете. Её проверки рантайма написаны для Node.js, Python, Go, Bun и Deno, а не для Java, но закоммиченный .env она всё равно называет по имени, не открывая его, что полезно для любого репозитория. Подробнее - в статье предварительная проверка, а сторона панели описана в руководстве по деплоям.
Логи: консоль, файлы и диск#
Консоль показывает стандартный вывод процесса без фильтров и в реальном времени, с командной строкой, кнопками питания и графиками памяти и CPU относительно лимитов. Для большинства сервисов это и есть лог: Spring Boot, Quarkus и Logback по умолчанию пишут в консоль. Цветной вывод Spring Boot и то, как с ним обходится консоль, в порядке; если цветовые коды мешают в скопированном логе, spring.output.ansi.enabled=never их отключает.
Консоль - это живой просмотр, а не архив. Всё, что нужно прочитать после перезапуска, пишите ещё и в файл, причём с ограничением, потому что лимит диска настоящий: сервер, превысивший лимит диска во время работы, останавливается и блокируется, пока место не освободят. Для ротации Logback в Spring Boot нужны четыре параметра:
logging.file.name=logs/app.loglogging.logback.rollingpolicy.max-file-size=10MBlogging.logback.rollingpolicy.max-history=7logging.logback.rollingpolicy.total-size-cap=200MBДиск защищает именно total-size-cap; без него max-history хранит семь дней независимо от их размера. Относительный путь отсчитывается от папки сервера, которая и есть рабочий каталог. Читайте файлы в файловом менеджере или забирайте по SFTP.
Что логировать - отдельный вопрос, не связанный с тем, куда. Пишите не больше одной строки на запрос, ошибки логируйте со stack trace один раз и не записывайте тела запросов и заголовки, в которых могут быть токены. Подробнее - в статье логи, которые стоит хранить.
Остановка - часть той же картины. Кнопка остановки посылает Ctrl+C, который JVM воспринимает как SIGINT и отвечает запуском shutdown hooks. Spring Boot использует этот момент для плавного завершения: при server.shutdown=graceful он перестаёт принимать новые запросы и даёт доработать текущим, не дольше spring.lifecycle.timeout-per-shutdown-phase (по умолчанию 30 секунд). Благодаря этому перезапуск при деплое по push не обрывает запросы на полпути. Подробности - в статье плавная остановка и health checks.
Если Java-сервер не запускается#
Ошибки, которые встречаются на практике, и что каждая означает:
- `Error: Unable to access jarfile app.jar`. Файла, указанного в
JARFILE, нет. Проверьте регистр имени, папку и не положил ли деплой из GitHub код в подпапку. - `no main manifest attribute, in app.jar`. Это jar библиотеки, а не исполняемый. Соберите его плагином Boot, Shade или Shadow либо загрузите нужный файл - в Gradle не тот, что с
-plain. - `UnsupportedClassVersionError ... class file version 65.0`. Скомпилировано под более новую Java, чем на сервере. Смените образ на вкладке Startup или поставьте в сборке подходящий release.
- `NoClassDefFoundError` или `ClassNotFoundException` для класса библиотеки. Зависимостей нет в jar. Исправление то же, что и для ошибки манифеста.
- Запускается, пишет, что готов, но адрес не отвечает. Он слушает другой порт или
localhost. Найдите в логе запуска строку с портом и сравните с выделенным. - Панель вечно показывает "Starting". Приложение работает, но ни разу не напечатало ни одно из стартовых слов. Выведите
ListeningилиReady, когда оно поднимется. - Перезапускается каждые несколько минут без ошибки. Процесс упёрся в лимит памяти. См. раздел про память.
- `APPLICATION FAILED TO START` от Spring Boot. Прочитайте строки "Description" и "Action" под ним; обычно они верны. Чаще всего это отсутствующий URL источника данных, то есть переменная не сохранилась или в ней опечатка.
FAQ#
Может ли сервер собрать мой проект через Maven или Gradle?
Нет. Java-сервер запускает готовый jar и не имеет шага сборки. Собирайте у себя или в CI - обычно для этого служит приведённый выше workflow GitHub Actions - и деплойте только jar.
Можно ли изменить -Xmx или другие флаги JVM?
Из панели нет. Команда запуска фиксирована и задаёт потолок heap в 80% памяти тарифа, а JAVA_TOOL_OPTIONS входит в число зарезервированных имён переменных. Вы можете уменьшить память вне heap изнутри приложения, а если у вас есть измеренная потребность в других флагах, откройте тикет.
Сколько памяти нужно небольшому Java-сервису?
Обычный Java или Javalin API, сервис на Quarkus или бот на JDA хорошо работают на 1 GB. Веб-приложению на Spring Boot с JPA комфортнее на 2 GB, потому что память JVM вне heap занимает бóльшую долю небольшого тарифа.
Нужен ли отдельный порт для Spring Boot Actuator?
Обычно нет. Держать Actuator на основном порту и открывать только нужные endpoint'ы, например health, проще, чем управлять вторым портом, а строка management.endpoints.web.exposure.include=health делает именно это. Всё более чувствительное, например env или heapdump, не должно быть открыто на публичном адресе.
Можно ли запустить WAR-файл?
Напрямую нет - сервлет-контейнер не установлен. Spring Boot может упаковать то же приложение как исполняемый jar со встроенным Tomcat, это обычный путь. Старое приложение, существующее только в виде WAR, придётся сначала переупаковать.
Остаётся ли сервер на выбранной мной версии Java?
Да. Версия - это образ, на котором работает сервер, выбранный на вкладке Startup или GitHub, и она не меняется, пока вы сами её не смените.


