Приложение ASP.NET Core деплоится с GitHub в четыре хода: сервер получает репозиторий, dotnet publish превращает проект в папку скомпилированных файлов, dotnet YourApp.dll запускает Kestrel, а Kestrel слушает порт, выданный хостом, на всех интерфейсах. Почти каждый неудачный первый деплой - это один из этих четырёх шагов, сделанный чуть-чуть неправильно: публикация берёт не тот проект, стартовая команда завершается вместо того, чтобы оставаться на переднем плане, или Kestrel слушает localhost:5000, потому что никто не сказал ему иного. В этой статье каждый шаг разобран в том порядке, в котором он выполняется, вместе с настройками, которые важны только тогда, когда приложение уже не на вашем ноутбуке.
Ничто здесь не привязано к конкретному хостингу. В примерах используется панель с Git-деплоем и обратным прокси впереди, потому что именно на таком работает большинство небольших .NET-приложений, но те же команды работают и на VDS с systemd.
Как Git-деплой запускает .NET-приложение#
Git-деплой - это не магия и не конвейер сборки. Это клон (или pull при последующих деплоях) в файловую систему сервера, после которого выполняется стартовая команда. Для .NET-проекта стартовая команда делает две вещи: собирает код, а затем запускает его.
Есть два честных способа это устроить, и от выбора зависит, сколько длится перезапуск.
- Сборка на сервере. Стартовая команда выполняет
dotnet publish, а затем запускает результат. Ничего скомпилированного в Git не попадает. Цена в том, что каждый запуск - не только каждый деплой - платит за restore и компиляцию, а это 20 секунд на быстрой машине и минута или больше на половине ядра. - Сборка в CI, запуск на сервере. Workflow GitHub Actions публикует приложение и коммитит результат в отдельную ветку (назовём её
deploy), а сервер отслеживает эту ветку. Стартовая команда - это толькоdotnet App.dll. Запуски быстрые, и серверу никогда не нужна память под SDK, ценой файла workflow, который придётся поддерживать.
Большинству стоит начать с первого варианта и перейти ко второму, когда время перезапуска начнёт раздражать. Оба описаны ниже.
Что нужно репозиторию, чтобы он задеплоился#
.NET-репозиторий, который чисто собирается на вашей машине, всё равно может не собраться на сервере, обычно по одной из этих причин.
Проектов больше одного. dotnet publish без аргумента ищет файл проекта или решения в текущем каталоге. Если в корне лежит .sln с веб-проектом, библиотекой классов и тестовым проектом, публикация решения публикует их все в одну и ту же папку, и вы получаете либо мешанину, либо ошибку о том, что несколько проектов пишут один и тот же файл. Укажите нужный проект явно: dotnet publish src/Shop.Web/Shop.Web.csproj.
`bin` и `obj` закоммичены. Их там быть не должно. Закоммиченная папка obj несёт состояние restore с вашей машины (включая абсолютные пути к вашему кэшу NuGet), и сборка на сервере затем падает так, будто повреждены пакеты. Стандартный вывод dotnet new gitignore исключает обе папки.
Версия SDK не закреплена. Файл global.json в корне сообщает команде dotnet, какой SDK использовать:
{ "sdk": { "version": "10.0.100", "rollForward": "latestFeature" }}rollForward: latestFeature принимает любую более позднюю feature band той же мажорной версии, так что 10.0.100 подходит и под 10.0.2xx. Без global.json ваш код собирает самый новый установленный SDK, и обычно это нормально. Если же global.json называет SDK, которого на сервере нет, сборка останавливается с «A compatible .NET SDK was not found», поэтому закрепляйте мажорную версию, на которую нацелены, а не точный патч с вашего ноутбука. На какую мажорную версию нацеливаться, разобрано в статье версии .NET и поддержка LTS.
Версии пакетов плавают. Если хотите, чтобы сервер восстанавливал ровно то, что вы тестировали, включите lock-файл. Добавьте в проект <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>, закоммитьте созданный им packages.lock.json и восстанавливайте пакеты на сервере с --locked-mode: тогда restore падает, а не молча разрешает что-то новое.
Стартовая команда#
Стартовая команда выполняется при каждом запуске контейнера, а не только после push. Для подхода со сборкой на сервере она выглядит так:
dotnet publish src/Shop.Web/Shop.Web.csproj -c Release -o out \ --disable-build-servers && exec dotnet out/Shop.Web.dllЧто делает каждая часть:
-c Releaseсобирает с оптимизациями. Начиная с .NET 8,dotnet publishпо умолчанию использует Release для проектов, нацеленных на .NET 8 и новее, но явное указание ничего не стоит и защищает старые проекты.-o outкладёт опубликованные файлы в известную папку. Без него они попадают вbin/Release/net10.0/publish/, а этот путь меняется вместе с целевым фреймворком.--disable-build-servers(SDK 7 и новее) не даёт MSBuild и серверу компилятора Roslyn оставаться в памяти после сборки. На рабочей машине эти фоновые процессы ускоряют следующую сборку. На сервере с 1 ГБ они сидят рядом с вашим приложением и держат несколько сотен мегабайт памяти ради сборки, которая не случится до следующего перезапуска.execзаменяет оболочку процессом .NET, так что сигнал остановки от панели доходит прямо до вашего приложения, и оно может завершиться чисто.
Команда должна оставаться на переднем плане. Скрипт, который отправляет процесс в фон, или стартовая команда, которая только собирает, выглядят как завершившееся приложение, и его будут перезапускать по кругу.
При подходе со сборкой в CI workflow публикует приложение на раннере GitHub и пушит папку с результатом в ветку deploy, а стартовая команда сжимается до exec dotnet Shop.Web.dll. Перезапуск тогда занимает секунду-две. Плата за это - опубликованный результат в ветке должен соответствовать runtime на сервере: framework-dependent сборке для .NET 10 нужен там runtime .NET 10. Статья dotnet publish и его параметры runtime объясняет разницу между framework-dependent и self-contained выводом, который эту зависимость убирает.
Kestrel, порт и ASPNETCORE_URLS#
Kestrel - веб-сервер, встроенный в ASP.NET Core. Он быстрый, готов к продакшену и без настройки слушает http://localhost:5000, а внутри контейнера это значит, что снаружи до него никто не достучится. Файл launchSettings.json, задающий ваши локальные порты, читают только dotnet run и Visual Studio; опубликованное приложение, запущенное через dotnet App.dll, полностью его игнорирует.
У вашего тарифа есть выделенный порт, он показан в панели, и приложение должно слушать этот порт на всех интерфейсах. Kestrel берёт адреса из нескольких мест в таком порядке приоритета (более позднее побеждает более раннее):
| Источник | Пример | Примечания |
|---|---|---|
ASPNETCORE_HTTP_PORTS | 8080 | .NET 8 и новее. Только порты, все интерфейсы |
ASPNETCORE_URLS | http://0.0.0.0:8080 | Полные URL; переопределяет переменную с портами |
Аргумент --urls | --urls http://0.0.0.0:8080 | Переопределяет окружение |
Kestrel:Endpoints в конфигурации | appsettings.json | Заменяет настройки URL выше |
Вызовы Listen в коде | ListenAnyIP(port) | Тоже заменяет настройки URL |
Для приложения в панели самая простая правильная настройка - переменная окружения на вкладке Startup:
ASPNETCORE_URLS=http://0.0.0.0:25571Используйте номер порта, который на самом деле показан у вашего тарифа. Если не хотите копировать порт в два места, читайте его в коде. Панели передают выделенный порт процессу через переменную окружения (в панелях на базе Pterodactyl это SERVER_PORT), и к нему можно привязаться явно:
var builder = WebApplication.CreateBuilder(args);var port = Environment.GetEnvironmentVariable("PORT") ?? Environment.GetEnvironmentVariable("SERVER_PORT");if (port is not null){ builder.WebHost.ConfigureKestrel(k => k.ListenAnyIP(int.Parse(port)));}var app = builder.Build();app.MapGet("/healthz", () => Results.Ok(new { ok = true }));app.Run();ListenAnyIP привязывается к этому порту и по IPv4, и по IPv6. http://+:8080 и http://*:8080 в ASPNETCORE_URLS означают то же самое, что 0.0.0.0; работают все три варианта.
Слушайте обычный HTTP. TLS завершается на прокси перед приложением, так что Kestrel не нужен сертификат, а настройка HTTPS-эндпоинта внутри контейнера даёт проблему с сертификатом без всякой пользы. Сертификата разработки из шаблона ASP.NET Core на сервере не существует; если в вашей конфигурации всё ещё есть URL https://, запуск падает с «Unable to configure HTTPS endpoint. No server certificate was specified».
Окружение, appsettings и строки подключения#
ASP.NET Core берёт окружение из ASPNETCORE_ENVIRONMENT (или DOTNET_ENVIRONMENT), а если ни одна из них не задана, окружение - Production. Для сервера это правильное значение по умолчанию, и оно меняет заметное поведение: страница исключений для разработчика выключена, appsettings.Development.json не загружается, а user secrets не читаются.
Последнее - классическая ошибка первого деплоя. Локально всё работало, потому что пароль от базы лежал в user secrets; на сервере его просто нет, и приложение падает на первом же запросе с пустой строкой подключения. Конфигурация на сервере берётся из appsettings.json, затем из appsettings.Production.json, затем из переменных окружения, и окружение побеждает.
Храните секреты в переменных окружения на вкладке Startup. Вложенные ключи пишутся через двойное подчёркивание:
ConnectionStrings__Default=Host=203.0.113.10;Port=5432;Database=shop;Username=shop;Password=...Stripe__SecretKey=sk_live_...После этого builder.Configuration.GetConnectionString("Default") читает первую переменную, а builder.Configuration["Stripe:SecretKey"] - вторую. Полная схема слоёв, паттерн options и проверка настроек при запуске описаны в статье конфигурация и секреты в ASP.NET Core. В RE:NODE тариф для приложений включает два слота баз данных, которые создаются в панели со сгенерированными хостом, пользователем и паролем, так что строку подключения вы копируете на вкладку Startup, а не придумываете. Если вам нужен SQL Server, MySQL или PostgreSQL как отдельный сервер, это хостинг баз данных; пакеты провайдеров для каждой из них разобраны в статье .NET с PostgreSQL, MySQL или SQL Server.
Ещё один файл, который должен переживать деплои, - кольцо ключей Data Protection. ASP.NET Core шифрует им cookie аутентификации и antiforgery-токены, и по умолчанию оно лежит в домашнем каталоге пользователя. Если это место не постоянное или ключи генерируются заново, при перезапуске разлогинивается каждый пользователь. Сохраняйте их туда, где они точно переживут повторный деплой, и вне отслеживаемого репозитория:
builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo("/home/container/keys"));Поправьте путь на тот, где лежат постоянные файлы вашего сервера. Что ломается, когда ключи неожиданно меняются, объясняет статья основы аутентификации в ASP.NET Core.
Домен, HTTPS и проксированные заголовки#
Когда Kestrel работает на обычном HTTP, домен и сертификат - забота прокси. В RE:NODE каждый тариф для приложений включает слот прокси: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне в 21 день. Настоящий адрес клиента приходит в X-Forwarded-For.
Приложению нужно сказать, чтобы оно верило этому заголовку, иначе оно будет считать, что каждый запрос пришёл от прокси по обычному HTTP. Если этого не сделать, ломаются две вещи:
HttpContext.Connection.RemoteIpAddress- это адрес прокси, так что логи и ограничения частоты считают всех посетителей одним человеком.Request.Schemeравенhttp, так чтоUseHttpsRedirectionперенаправляет запрос, который уже пришёл по HTTPS, и браузер ходит по кругу, пока не сдастся с «too many redirects».
Исправление - middleware проксированных заголовков, зарегистрированное первым:
builder.Services.Configure<ForwardedHeadersOptions>(o =>{ o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;});var app = builder.Build();app.UseForwardedHeaders();По умолчанию middleware доверяет только прокси на loopback, так что прокси с другим адресом игнорируется, пока вы не добавите его в KnownProxies. Как сделать это правильно и не дать никому подделать свой IP - вся тема статьи ASP.NET Core за обратным прокси, вместе с HSTS и WebSocket. Если сама идея вам внове, начните со статьи что на самом деле делает обратный прокси.
Первый деплой по шагам#
- Запушьте репозиторий с
global.json, безbinиobj, и с эндпоинтом проверки состояния вроде/healthz, который возвращает 200, не обращаясь к базе данных. - Подключите репозиторий в панели. В RE:NODE это только GitHub, через GitHub App с краткоживущими токенами, так что приватные репозитории тоже работают.
- Задайте стартовую команду: публикация в
out, затемexec dotnet out/YourApp.dll. - На вкладке Startup задайте
ASPNETCORE_URLSкакhttp://0.0.0.0:плюс ваш выделенный порт и добавьте строки подключения и секреты. - Запустите сервер и следите за консолью. Здоровый запуск заканчивается строками
Now listening on: http://0.0.0.0:25571иApplication started.Если вместо этого вы видитеhttp://localhost:5000, шаг 4 не сработал. - Запросите эндпоинт проверки состояния по IP и порту. Затем настройте слот прокси и запросите его по домену.
- Включите два переключателя деплоя, если они вам нужны: pull при каждом запуске и деплой при push, который перезапускает только уже работающий сервер, так что сервер, остановленный вами намеренно, останется остановленным. Каждый деплой записывается, и по этой записи видно, какой коммит сейчас в работе.
Сторона панели пошагово, со скриншотами, показана в руководстве по деплоям. Процесс тот же, что и для Node; подробнее о том, как взаимодействуют pull при запуске и деплой при push, написано в статье деплой приложения Node.js с GitHub.
Разбор проблем первого деплоя#
«Now listening on: http://localhost:5000», и ничего не подключается. Kestrel так и не получил ваш адрес. Проверьте написание ASPNETCORE_URLS и то, что значение начинается с http://. Опечатка в имени переменной окружения не даёт вообще никакой ошибки.
«A compatible .NET SDK was not found». global.json называет SDK, который не установлен. Ослабьте его до мажорной версии через rollForward или удалите.
«You must install or update .NET to run this application». Framework-dependent вывод нацелен на runtime, которого на сервере нет, например приложение net10.0 на машине, где есть только runtime .NET 8. Нацельтесь на установленную версию или публикуйте self-contained.
«Failed to bind to address ... address already in use». Порт держит другой процесс (часто ваш же предыдущий экземпляр или забытый сервер сборки), или две конечные точки в конфигурации указывают один и тот же порт.
Сборка обрывается на полпути без ошибки. Память. Компилятор остановили на лимите; см. предупреждение выше.
Приложение перезапускается каждые несколько минут. Прочитайте последние строки перед каждым перезапуском. Необработанное исключение при запуске, например неудачное подключение к базе в Program.cs, завершает процесс, платформа запускает его снова, и цикл повторяется. Падайте с понятным сообщением, а не со стеком вызовов, и посмотрите статью почему ваш сервер постоянно перезапускается о том, как обнаруживаются циклы перезапусков.
После деплоя разлогинивается каждый пользователь. Ключи Data Protection не были сохранены. См. выше.
Статические файлы отдают 404 в продакшене. dotnet publish копирует wwwroot только если файлы входят в проект. Файлы, добавленные фронтенд-сборкой, которая выполняется после публикации, или исключённые в .csproj, в out никогда не попадают. Сначала соберите фронтенд, потом публикуйте.
FAQ#
Нужен ли IIS или nginx перед Kestrel?
Нет. Kestrel уже много лет поддерживается как пограничный сервер. Вам нужны завершение TLS и домен, а их даёт слот прокси; nginx внутри контейнера это дублирует и добавляет второй набор таймаутов, которые придётся настраивать.
Стоит ли коммитить опубликованный вывод в основную ветку?
Не в ту ветку, в которой вы ведёте разработку. Скомпилированные файлы в одной ветке с исходниками дают огромные диффы и конфликты слияния. Если нужны быстрые перезапуски, пусть CI публикует в отдельную ветку, а сервер смотрит на неё.
Почему каждый перезапуск длится так долго?
Потому что стартовая команда каждый раз выполняет restore и компиляцию. Такова цена сборки на сервере. Предварительная сборка в CI или небольшое решение сокращают это до секунд.
Можно ли выполнять миграции базы данных в рамках деплоя?
Можно, но подумайте, что будет, если миграция упадёт на полпути. Статья миграции EF Core в продакшене сравнивает применение миграций при запуске с migration bundles и идемпотентными скриптами.
На какую версию .NET нацеливать новое приложение?
На текущий релиз с долгосрочной поддержкой, а на октябрь 2026 года это .NET 10. Он поддерживается до ноября 2028 года, так что в течение года обновляться вас никто не заставит.
Сколько памяти нужно небольшому API на ASP.NET Core?
Minimal API в простое использует заметно меньше 100 МБ; типичное приложение с EF Core и несколькими десятками эндпоинтов держится в пределах 100-300 МБ при небольшой нагрузке. Сборке на сервере нужно больше, чем самому приложению. Почему цифра, которую вы видите, зависит от режима GC, объясняет статья память и сборка мусора в .NET.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.