Guia completo para fazer o deploy de uma aplicação C# .NET 10 em uma VPS Ubuntu na OCI — com duas instâncias rodando em paralelo, load balancing no Nginx, rolling deploy sem downtime via GitHub Actions e rsync, e SSL automático pelo Certbot.
Baseado em: deploy de produção com .NET 10 em VPS OCI Ubuntu 24.04. Testado em: Ubuntu 24.04 LTS, .NET 10, Nginx, Certbot, GitHub Actions.
Por que duas instâncias?
Rodar duas instâncias da mesma aplicação em portas diferentes traz duas vantagens concretas:
Load balancing real: o Nginx distribui as requisições em round-robin entre :5001 e :5002. Se uma instância estiver sob carga, a outra absorve parte do tráfego.
Rolling deploy sem downtime: ao atualizar, paramos uma instância por vez. Enquanto a instância 1 está sendo atualizada e reiniciando, a instância 2 continua atendendo 100% do tráfego — e vice-versa. O usuário final não percebe interrupção.
Essa arquitetura é o padrão adotado aqui, não uma opção. Toda a pipeline e a configuração do Nginx são construídas ao redor disso.
1. Pré-requisitos
Na VPS
- Ubuntu 24.04 LTS
- Nginx instalado e rodando
- UFW configurado
- Certbot instalado
- Usuário
deploycom chave SSH configurada - Diretório
/opt/app-namecriado
No GitHub
Configure os seguintes secrets em Settings → Secrets and variables → Actions:
| Secret | Descrição |
|---|---|
SSH_HOST |
IP ou hostname da VPS |
SSH_PRIVATE_KEY |
Chave privada SSH do usuário deploy |
APP_DOMAIN |
Domínio da aplicação (ex: api.straccini.com) |
Adicione secrets extras para variáveis específicas da aplicação (strings de conexão, API keys, etc.).
2. Instalação do .NET 10 Runtime na VPS
O build acontece no GitHub Actions — a VPS só precisa do runtime para executar a aplicação publicada.
# Adicionar repositório oficial da Microsoft
wget https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb \
-O packages-microsoft-prod.deb
dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
apt update
# Runtime do ASP.NET Core 10 (inclui o .NET Runtime base)
apt install -y aspnetcore-runtime-10.0
Verifique:
dotnet --version
dotnet --list-runtimes
3. Usuário da aplicação e estrutura de diretórios
O deploy usa dois usuários distintos com responsabilidades separadas:
deploy— usado pelo GitHub Actions para fazer SSH e transferir arquivosapp-name— usuário de sistema sem shell, que executa a aplicação
APP=app-name
# Usuário da aplicação — sem shell, sem home
useradd --system --no-create-home --shell /usr/sbin/nologin $APP
# Diretório principal da aplicação
mkdir -p /opt/$APP
chown $APP:$APP /opt/$APP
chmod 750 /opt/$APP
# Diretório de staging (usado durante o deploy para swap atômico)
mkdir -p /opt/${APP}-staging
chown deploy:deploy /opt/${APP}-staging
chmod 750 /opt/${APP}-staging
4. Chave SSH e sudoers
Chave SSH para o GitHub Actions
Na sua máquina local:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ~/.ssh/deploy_app-name -N ""
Copie a chave pública para o servidor. Em ambientes OCI o ssh-copy-id pode falhar antes do usuário deploy estar configurado — use o método manual:
# No servidor, logado como ubuntu:
sudo mkdir -p /home/deploy/.ssh
echo "COLE_A_CHAVE_PUBLICA_AQUI" | sudo tee -a /home/deploy/.ssh/authorized_keys
sudo chmod 700 /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
sudo chown -R deploy:deploy /home/deploy/.ssh
Adicione o conteúdo da chave privada como secret SSH_PRIVATE_KEY no GitHub.
Sudoers — permissões cirúrgicas para o deploy
O usuário deploy precisa de sudo apenas para parar/iniciar os serviços e ajustar permissões. Nada além disso:
APP=app-name
cat > /etc/sudoers.d/${APP}-deploy << EOF
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl start ${APP}-1
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl start ${APP}-2
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop ${APP}-1
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop ${APP}-2
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl status ${APP}-1
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl status ${APP}-2
deploy ALL=(ALL) NOPASSWD: /usr/bin/rsync
deploy ALL=(ALL) NOPASSWD: /usr/bin/chown -R ${APP}\:${APP} /opt/${APP}
deploy ALL=(ALL) NOPASSWD: /usr/bin/chmod -R 750 /opt/${APP}
deploy ALL=(ALL) NOPASSWD: /usr/bin/mkdir -p /opt/${APP}-staging
deploy ALL=(ALL) NOPASSWD: /usr/bin/rm -rf /opt/${APP}-staging
EOF
chmod 440 /etc/sudoers.d/${APP}-deploy
visudo -c -f /etc/sudoers.d/${APP}-deploy
5. Arquivo de ambiente
Crie o arquivo de variáveis de ambiente que o systemd vai carregar em cada instância. As instâncias diferem apenas na porta:
APP=app-name
# Instância 1 — porta 5001
cat > /opt/$APP/environment-1 << EOF
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://127.0.0.1:5001
DOTNET_PRINT_TELEMETRY_MESSAGE=false
# Exemplo de variáveis da aplicação
ConnectionStrings__DefaultConnection=Server=127.0.0.1;Database=mydb;User=myapp;Password=change-me;
AppSettings__ApiKey=change-me
EOF
# Instância 2 — porta 5002
cp /opt/$APP/environment-1 /opt/$APP/environment-2
sed -i 's/5001/5002/' /opt/$APP/environment-2
# Permissões restritas — só o usuário da app lê
chown $APP:$APP /opt/$APP/environment-1 /opt/$APP/environment-2
chmod 640 /opt/$APP/environment-1 /opt/$APP/environment-2
💡 Variáveis de ambiente vs appsettings
O .NET mapeia variáveis de ambiente com__ (duplo underscore) para a hierarquia do appsettings.json. ConnectionStrings__DefaultConnection equivale a ConnectionStrings:DefaultConnection.
6. Serviços systemd — duas instâncias
Crie um arquivo de serviço para cada instância. A diferença entre eles é a porta via EnvironmentFile e o SyslogIdentifier.
Instância 1
APP=app-name
cat > /etc/systemd/system/${APP}-1.service << EOF
[Unit]
Description=${APP} .NET 10 API (instância 1)
After=network.target
Wants=network-online.target
[Service]
Type=notify
User=${APP}
Group=${APP}
WorkingDirectory=/opt/${APP}
ExecStart=/usr/bin/dotnet /opt/${APP}/${APP}.dll
EnvironmentFile=/opt/${APP}/environment-1
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${APP}-1
Restart=on-failure
RestartSec=5s
KillSignal=SIGINT
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
Instância 2
cat > /etc/systemd/system/${APP}-2.service << EOF
[Unit]
Description=${APP} .NET 10 API (instância 2)
After=network.target
Wants=network-online.target
[Service]
Type=notify
User=${APP}
Group=${APP}
WorkingDirectory=/opt/${APP}
ExecStart=/usr/bin/dotnet /opt/${APP}/${APP}.dll
EnvironmentFile=/opt/${APP}/environment-2
StandardOutput=journal
StandardError=journal
SyslogIdentifier=${APP}-2
Restart=on-failure
RestartSec=5s
KillSignal=SIGINT
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
Ative e inicie os dois serviços:
systemctl daemon-reload
systemctl enable ${APP}-1 ${APP}-2
systemctl start ${APP}-1 ${APP}-2
systemctl status ${APP}-1 ${APP}-2
💡 Type=notify vs Type=simple
Type=notify instrui o systemd a aguardar o sinal de "pronto" do Kestrel antes de considerar o serviço iniciado. Isso evita que o health check dispare antes da aplicação estar de fato aceitando conexões. Requer o pacote libsystemd-dev no host.
7. Nginx — load balancing com upstream
Com duas instâncias em :5001 e :5002, o Nginx atua como proxy reverso e distribui as requisições em round-robin.
APP=app-name
cat > /etc/nginx/sites-available/$APP << 'NGINXEOF'
upstream app-name_backend {
# Round-robin automático entre as duas instâncias
server 127.0.0.1:5001;
server 127.0.0.1:5002;
# Reutiliza conexões HTTP/1.1 com o upstream
keepalive 32;
}
# HTTP → HTTPS redirect
server {
listen 80;
listen [::]:80;
server_name seu-dominio.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS — proxy reverso com load balancing
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name seu-dominio.com;
# Certbot preenche automaticamente
ssl_certificate /etc/letsencrypt/live/seu-dominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/seu-dominio.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Headers de segurança
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API — todas as rotas
location / {
proxy_pass http://app-name_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
proxy_send_timeout 60s;
}
# Health check — sem poluir os logs
location /health {
proxy_pass http://app-name_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
access_log off;
}
}
NGINXEOF
ln -s /etc/nginx/sites-available/$APP /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
8. SSL com Certbot
O DNS precisa estar apontando para o IP da VPS antes deste passo.
certbot --nginx -d seu-dominio.com
Teste a renovação automática:
certbot renew --dry-run
O Certbot instala um timer systemd que renova os certificados automaticamente antes do vencimento.
9. Configuração do Kestrel — ForwardedHeaders
Como o Kestrel roda atrás do Nginx, ele precisa confiar nos headers encaminhados para identificar corretamente o IP real do cliente e o protocolo:
// Program.cs — adicione antes de app.UseRouting()
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
Sem isso, HttpContext.Connection.RemoteIpAddress sempre retorna 127.0.0.1 (o Nginx) e redirects HTTPS podem entrar em loop.
HealthChecks
builder.Services.AddHealthChecks()
.AddCheck("self", () => HealthCheckResult.Healthy())
.AddCheck("ready", () => HealthCheckResult.Healthy(), tags: ["ready"]);
// ...
app.MapHealthChecks("/health");
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = _ => false // liveness — retorna 200 se o processo está vivo
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = hc => hc.Tags.Contains("ready")
});
10. GitHub Actions — CI e rolling deploy
O workflow é dividido em dois arquivos: ci.yml para validação em pull requests, e deploy.yml para o deploy em produção ao fazer push na main.
ci.yml — build e testes em pull requests
# .github/workflows/ci.yml
name: CI
on:
pull_request:
workflow_dispatch:
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --no-restore --configuration Release
- name: Test
run: |
dotnet test --no-build --configuration Release --verbosity normal \
/p:CollectCoverage=true /p:CoverletOutputFormat=opencover
deploy.yml — rolling deploy sem downtime
O deploy segue esta sequência:
- Build e publish no runner do GitHub Actions
- rsync dos artefatos para
/opt/app-name-staging/(instâncias ainda servindo tráfego) - Rolling deploy da instância 1: stop → swap de arquivos → start → health check em
:5001 - Se a instância 1 passou, rolling deploy da instância 2: stop → start → health check em
:5002 - Health check final via Nginx (HTTPS público)
- Limpeza do diretório de staging
# .github/workflows/deploy.yml
name: Build & Deploy
on:
push:
branches:
- main
workflow_dispatch:
env:
DOTNET_VERSION: '10.0.x'
PROJECT_PATH: 'src/app-name/app-name.csproj'
PUBLISH_DIR: './publish'
REMOTE_DIR: '/opt/app-name'
SSH_USER: deploy
SSH_HOST: ${{ secrets.SSH_HOST }}
SERVICE_1: app-name-1
SERVICE_2: app-name-2
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
# ── 1. Checkout ──────────────────────────────────────────────────────────
- name: Checkout repository
uses: actions/checkout@v4
# ── 2. Setup .NET 10 ─────────────────────────────────────────────────────
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
# ── 3. Restore & Publish ─────────────────────────────────────────────────
- name: Restore dependencies
run: dotnet restore ${{ env.PROJECT_PATH }}
- name: Publish
run: |
dotnet publish ${{ env.PROJECT_PATH }} \
--configuration Release \
--output ${{ env.PUBLISH_DIR }} \
--no-restore \
--self-contained false
# ── 4. Setup SSH ─────────────────────────────────────────────────────────
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ssh-keyscan -H ${{ env.SSH_HOST }} >> ~/.ssh/known_hosts
# ── 5. rsync para o diretório de staging ─────────────────────────────────
# As duas instâncias continuam servindo tráfego normalmente durante esta etapa
- name: Upload release to staging
run: |
ssh ${{ env.SSH_USER }}@${{ env.SSH_HOST }} \
"sudo mkdir -p /opt/app-name-staging && \
sudo chown deploy:deploy /opt/app-name-staging"
rsync -avz --delete \
-e "ssh -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no" \
${{ env.PUBLISH_DIR }}/ \
${{ env.SSH_USER }}@${{ env.SSH_HOST }}:/opt/app-name-staging/
# ── 6. Rolling deploy — instância 1 ──────────────────────────────────────
- name: Rolling deploy — instance 1
run: |
ssh ${{ env.SSH_USER }}@${{ env.SSH_HOST }} << 'ENDSSH'
set -e
echo "==> Parando instância 1..."
sudo systemctl stop app-name-1
echo "==> Sincronizando arquivos do staging..."
sudo rsync -a --delete /opt/app-name-staging/ /opt/app-name/
sudo chown -R app-name:app-name /opt/app-name
sudo chmod -R 750 /opt/app-name
echo "==> Iniciando instância 1..."
sudo systemctl start app-name-1
echo "==> Aguardando instância 1 em :5001..."
for i in $(seq 1 12); do
if curl -sf http://127.0.0.1:5001/health > /dev/null 2>&1; then
echo "✅ Instância 1 saudável (tentativa $i)"
exit 0
fi
echo "Tentativa $i — aguardando..."
sleep 5
done
echo "❌ Instância 1 não respondeu — abortando"
exit 1
ENDSSH
# ── 7. Rolling deploy — instância 2 ──────────────────────────────────────
# Só executa se a instância 1 passou no health check
- name: Rolling deploy — instance 2
run: |
ssh ${{ env.SSH_USER }}@${{ env.SSH_HOST }} << 'ENDSSH'
set -e
echo "==> Parando instância 2..."
sudo systemctl stop app-name-2
echo "==> Iniciando instância 2 (arquivos já no lugar)..."
sudo systemctl start app-name-2
echo "==> Aguardando instância 2 em :5002..."
for i in $(seq 1 12); do
if curl -sf http://127.0.0.1:5002/health > /dev/null 2>&1; then
echo "✅ Instância 2 saudável (tentativa $i)"
exit 0
fi
echo "Tentativa $i — aguardando..."
sleep 5
done
echo "❌ Instância 2 não respondeu — abortando"
exit 1
ENDSSH
# ── 8. Cleanup do staging ─────────────────────────────────────────────────
- name: Cleanup staging
if: always()
run: |
ssh ${{ env.SSH_USER }}@${{ env.SSH_HOST }} \
"sudo rm -rf /opt/app-name-staging" || true
# ── 9. Health check final via Nginx ───────────────────────────────────────
- name: Final health check
run: |
HEALTH_URL="https://${{ secrets.APP_DOMAIN }}/health"
echo "Verificando $HEALTH_URL ..."
for i in {1..10}; do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" || echo "000")
echo "Tentativa $i — HTTP $STATUS"
if [ "$STATUS" = "200" ]; then
echo "✅ Deploy concluído — ambas as instâncias saudáveis"
exit 0
fi
sleep 5
done
echo "❌ Health check final falhou"
exit 1
11. Por que rsync em vez de scp ou tar?
O artigo anterior usava scp com um arquivo .tar.gz. A abordagem com rsync tem vantagens práticas em um cenário com staging:
| scp + tar | rsync | |
|---|---|---|
| Transferência | Sempre o pacote inteiro | Só os arquivos modificados |
| Swap atômico | Precisa extrair e mover | rsync --delete direto no staging |
| Visibilidade | Nenhuma durante a transferência | Progresso arquivo a arquivo |
| Retomada | Não | Sim (--partial) |
| Rollback manual | Requer manter o .tar.gz anterior |
Staging permanece intacto até o cleanup |
Com dois serviços lendo do mesmo /opt/app-name/, o fluxo via staging garante que a cópia dos arquivos acontece de forma completa antes de qualquer instância ser reiniciada.
12. Firewall — UFW e OCI Security List
ufw allow OpenSSH
ufw allow 'Nginx Full'
ufw enable
ufw status verbose
As portas 5001 e 5002 do Kestrel não são abertas externamente — todo o tráfego passa pelo Nginx na porta 443. Mantenha-as fechadas.
Em instâncias OCI com Security List (padrão OCI), espelhe as regras na console:
| Tipo | Protocolo | Porta | Descrição |
|---|---|---|---|
| Ingress | TCP | 22 | SSH |
| Ingress | TCP | 80 | HTTP (redirect) |
| Ingress | TCP | 443 | HTTPS |
13. Checklist pós-deploy
- DNS A record aponta para o IP correto da VPS
systemctl status app-name-1mostraactive (running)systemctl status app-name-2mostraactive (running)curl -s https://seu-dominio.com/healthretorna HTTP 200curl -s http://127.0.0.1:5001/healthretorna 200 direto no Kestrelcurl -s http://127.0.0.1:5002/healthretorna 200 direto no Kestrelcertbot renew --dry-runbem-sucedidonginx -tsem warnings- UFW permite apenas portas 22, 80, 443
/opt/app-name/environment-1eenvironment-2comchmod 640ForwardedHeadersconfigurado noProgram.cs- Rolling restart testado manualmente: parar instância 1, verificar que instância 2 atende
- GitHub Actions com badge verde na
main
14. Comandos úteis do dia a dia
# Logs em tempo real
journalctl -u app-name-1 -f
journalctl -u app-name-2 -f
# Status dos serviços
sudo systemctl status app-name-1 app-name-2
# Restart manual de uma instância
sudo systemctl restart app-name-1
sudo systemctl restart app-name-2
# Reload do Nginx (zero downtime — só recarrega config)
sudo systemctl reload nginx
# Verificar qual processo está escutando em uma porta
ss -tlnp | grep 5001
ss -tlnp | grep 5002
# Testar health check direto em cada instância
curl -s http://127.0.0.1:5001/health
curl -s http://127.0.0.1:5002/health
# Testar config do Nginx antes de recarregar
nginx -t
# Log de erros do Nginx
tail -f /var/log/nginx/error.log
# Verificar vencimento do certificado SSL
echo | openssl s_client -connect seu-dominio.com:443 2>/dev/null \
| openssl x509 -noout -dates
# Forçar renovação do certificado
certbot renew --force-renewal --nginx -d seu-dominio.com
# Publish manual da aplicação (no runner ou localmente)
dotnet publish src/app-name/app-name.csproj \
--configuration Release \
--self-contained false \
--output ./publish
Referência de variáveis
Substitua os placeholders pelos valores reais do seu projeto:
| Placeholder | Exemplo | Significado |
|---|---|---|
app-name |
pancake-api |
Nome do app — usado para usuário, diretório e serviços |
seu-dominio.com |
api.straccini.com |
Domínio apontando para a VPS |
your-server-ip |
152.67.xx.xx |
IP público da VPS |
app-name.csproj |
Pancake.Api.csproj |
Arquivo de projeto para o publish |
app-name.dll |
Pancake.Api.dll |
DLL de entrada compilada |
5001 |
5001 |
Porta interna da instância 1 |
5002 |
5002 |
Porta interna da instância 2 |