Deploy de Spring Boot + Angular + Nginx em VPS
Guia completo do zero: Docker, reverse proxy e SSL automático com Let's Encrypt.
1. Visão geral
A stack final roda inteira em containers Docker numa única VPS: um container Nginx recebe todo o tráfego (portas 80 e 443), serve os arquivos estáticos do Angular diretamente, e faz proxy reverso das rotas de API para o container do Spring Boot, que por sua vez fala com o MySQL.
Ao longo do manual, substitua os placeholders abaixo pelos valores reais do seu projeto:
| Placeholder | Exemplo real |
|---|---|
seudominio.com | domínio raiz do projeto |
app.seudominio.com | subdomínio do frontend |
api.seudominio.com | subdomínio da API |
deploy | usuário SSH não-root na VPS |
/var/www/app | diretório raiz do projeto na VPS |
app-nginx, app-api, app-mysql | nomes dos containers |
2. Preparar a VPS
2.1 Usuário não-root
Evite operar como root no dia a dia. Crie um usuário dedicado com sudo:
adduser deploy
usermod -aG sudo deploy
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
2.2 Hardening básico do SSH
Em /etc/ssh/sshd_config, desative login root e autenticação por senha (use só chave):
PermitRootLogin no
PasswordAuthentication no
sudo systemctl restart ssh
deploy via chave antes de encerrar a sessão root — assim você não fica trancado pra fora se algo estiver errado.
2.3 Firewall (UFW)
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status
2.4 Instalar Docker + Docker Compose
# repositório oficial do Docker
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# permite rodar docker sem sudo
sudo usermod -aG docker $USER
newgrp docker
docker --version
docker compose version
3. Estrutura do projeto
Organize o projeto na VPS assim (o mesmo padrão pode ser espelhado localmente pra facilitar o deploy):
/var/www/app
├── backend/ # código-fonte Spring Boot + Dockerfile
│ └── Dockerfile
├── frontend/
│ └── dist/app-frontend/ # build de produção do Angular
├── nginx/
│ ├── nginx.conf
│ ├── conf.d/
│ │ └── default.conf
│ └── certbot-webroot/ # usado pela validação SSL (seção 8)
├── docker-compose.yml
└── .env # segredos — nunca commitar
4. Backend (Spring Boot)
4.1 Dockerfile (multi-stage build)
FROM maven:3.9-eclipse-temurin-17 AS build
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
4.2 Variáveis de ambiente
Nunca hardcode segredos na imagem. Use um arquivo .env na raiz do projeto (fora do controle de versão) e referencie no docker-compose.yml:
MYSQL_ROOT_PASSWORD=troque-por-uma-senha-forte
JWT_SECRET=troque-por-um-segredo-longo-e-aleatorio
MAIL_USERNAME=seu-email@exemplo.com
MAIL_PASSWORD=senha-de-app-do-email
EXTERNAL_API_KEY=sua-chave-de-api-externa
.env ao .gitignore. Se algum segredo já foi commitado por engano, ele precisa ser rotacionado — trocar de lugar no repositório não invalida o valor exposto.
5. Frontend (Angular)
O Angular é compilado para arquivos estáticos e servido diretamente pelo Nginx — não roda em container próprio.
cd frontend
npm install
ng build --configuration production
# saída em frontend/dist/app-frontend/
Depois do build, sincronize a pasta dist/ pra VPS (via rsync, scp
ou pipeline de CI/CD). O Nginx aponta pra esse diretório via bind mount — não precisa rebuildar o container pra atualizar o frontend.
rsync -avz --delete frontend/dist/app-frontend/ deploy@SEU_IP:/var/www/app/frontend/dist/app-frontend/
6. Nginx (reverse proxy)
Um único container Nginx cuida de tudo: redireciona HTTP → HTTPS, serve o Angular no domínio do app e faz proxy reverso da API. Escrever a configuração certa desde o início — já incluindo o bloco de validação SSL da seção 8 — evita o retrabalho de editar tudo depois.
6.1 docker-compose.yml
services:
mysql:
image: mysql:8.0
container_name: app-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: app_db
volumes:
- mysql_data:/var/lib/mysql
networks:
- app-net
api:
build: ./backend
container_name: app-api
restart: always
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/app_db
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
MAIL_USERNAME: ${MAIL_USERNAME}
MAIL_PASSWORD: ${MAIL_PASSWORD}
EXTERNAL_API_KEY: ${EXTERNAL_API_KEY}
depends_on:
- mysql
networks:
- app-net
nginx:
image: nginx:alpine
container_name: app-nginx
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/certbot-webroot:/var/www/certbot
- /etc/letsencrypt:/etc/letsencrypt
- ./frontend/dist/app-frontend:/usr/share/nginx/html
depends_on:
- api
networks:
- app-net
volumes:
mysql_data:
networks:
app-net:
driver: bridge
6.2 conf.d/default.conf
Repare que cada bloco de porta 80 já nasce com o location /.well-known/acme-challenge/
— é o que permite emitir e renovar o certificado SSL sem precisar tirar o Nginx do ar (detalhe completo na seção 8).
# Redirect raiz para o app
server {
listen 80;
server_name seudominio.com www.seudominio.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://app.seudominio.com$request_uri;
}
}
server {
listen 443 ssl;
server_name seudominio.com www.seudominio.com;
ssl_certificate /etc/letsencrypt/live/seudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/seudominio.com/privkey.pem;
return 301 https://app.seudominio.com$request_uri;
}
# FRONTEND HTTP -> HTTPS
server {
listen 80;
server_name app.seudominio.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# FRONTEND HTTPS
server {
listen 443 ssl;
server_name app.seudominio.com;
ssl_certificate /etc/letsencrypt/live/seudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/seudominio.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
# BACKEND
server {
listen 80;
server_name api.seudominio.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name api.seudominio.com;
ssl_certificate /etc/letsencrypt/live/seudominio.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/seudominio.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
resolver 127.0.0.11 valid=30s;
set $upstream_endpoint http://api:8080;
proxy_pass $upstream_endpoint;
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;
}
}
set $upstream_endpoint antes do proxy_pass?
Força o Nginx a resolver o nome do serviço Docker (api) em tempo de requisição via o
resolver interno do Docker (127.0.0.11), em vez de resolver uma única vez na inicialização.
Evita erros 502 quando o container da API reinicia e recebe um novo IP interno.
7. DNS
No painel do seu provedor de domínio, crie registros A apontando cada subdomínio pro IP da VPS:
| Tipo | Nome | Valor |
|---|---|---|
| A | @ (seudominio.com) | IP da VPS |
| A | www | IP da VPS |
| A | app | IP da VPS |
| A | api | IP da VPS |
Propagação costuma levar de minutos a algumas horas. Confirme com:
dig +short app.seudominio.com
8. SSL com Let's Encrypt (Certbot)
Esta é a parte que mais costuma dar problema em setups com Docker — vale entender o porquê antes de copiar comandos.
8.1 O erro clássico: standalone + Docker
O método mais indicado nos tutoriais (certbot certonly --standalone) faz o próprio
Certbot abrir a porta 80 por conta própria pra responder o desafio de validação. Isso funciona se nada mais
estiver usando a porta 80 — mas nesse setup o container Nginx já está com ela ocupada o tempo todo.
Failed to renew certificate [...] Could not bind TCP port 80 because it is already in use by another process
8.2 A solução: webroot compartilhado
Em vez do Certbot abrir sua própria porta, ele escreve o arquivo de validação num diretório em disco — e o Nginx, que já está no ar, serve esse mesmo arquivo através de um bind mount compartilhado. Ninguém precisa soltar a porta 80.
8.3 Passo a passo
sudo apt install -y certbot
mkdir -p /var/www/app/nginx/certbot-webroot
O volume ./nginx/certbot-webroot:/var/www/certbot e os blocos
location /.well-known/acme-challenge/ já estão no docker-compose.yml
e no conf.d/default.conf da seção 6 — suba os containers antes de emitir o certificado:
cd /var/www/app
docker compose up -d nginx
docker exec app-nginx nginx -t
sudo certbot certonly --webroot -w /var/www/app/nginx/certbot-webroot \
-d seudominio.com -d www.seudominio.com -d api.seudominio.com -d app.seudominio.com \
--cert-name seudominio.com
docker exec app-nginx nginx -s reload
sudo certbot certificates
curl -I https://app.seudominio.com
8.4 Renovação automática
O pacote certbot já instala um timer do systemd que roda duas vezes ao dia e só
renova de fato quando o certificado está a 30 dias ou menos de expirar. Como o método é webroot,
isso acontece sem derrubar o Nginx. Verifique:
sudo certbot renew --dry-run # simula, não altera nada
sudo systemctl list-timers | grep certbot
webroot configurado, a renovação acontece sozinha
pra sempre. renew --dry-run é só um teste — a renovação real é automática.
9. Deploy e operação do dia a dia
9.1 Subir / atualizar tudo
cd /var/www/app
docker compose up -d --build
9.2 Atualizar só o backend
docker compose up -d --build api
9.3 Atualizar só o frontend
Como o Angular é servido via bind mount (não é rebuildado como imagem), basta gerar o build novo e sincronizar:
ng build --configuration production
rsync -avz --delete dist/app-frontend/ deploy@SEU_IP:/var/www/app/frontend/dist/app-frontend/
9.4 Logs
docker compose logs -f api
docker compose logs -f nginx
9.5 Status dos containers
docker compose ps
10. CI/CD com GitHub Actions
Até aqui, atualizar o servidor é manual: conectar via SSH, dar git pull e rodar
docker compose up -d --build. Com GitHub Actions, esse mesmo fluxo roda sozinho a
cada push na branch principal.
10.1 Pré-requisitos
- Projeto já versionado num repositório no GitHub, com a mesma estrutura de pastas usada na VPS (
backend/,frontend/,nginx/,docker-compose.yml). - O projeto já clonado em
/var/www/appna VPS, comgit remoteconfigurado. - O arquivo
.envconfigurado direto na VPS — ele nunca vai pro repositório, então não é tocado pelo deploy automático.
git pull do
workflow pode falhar por conflito. Trate a VPS como um ambiente só de destino do deploy — toda mudança de
código entra pelo Git.
10.2 Criar uma chave SSH dedicada ao deploy
Não reutilize sua chave pessoal. Gere um par novo, só pra esse propósito:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh_actions_deploy -N ""
cat gh_actions_deploy.pub | ssh deploy@SEU_IP 'cat >> ~/.ssh/authorized_keys'
10.3 Cadastrar segredos no GitHub
No repositório: Settings → Secrets and variables → Actions → New repository secret.
| Nome do secret | Valor |
|---|---|
VPS_HOST | IP ou domínio da VPS |
VPS_USER | deploy |
VPS_SSH_KEY | conteúdo completo da chave privada gh_actions_deploy gerada acima |
VPS_PORT | 22 (ou a porta customizada do SSH) |
10.4 Workflow: testar, buildar e fazer deploy
O workflow abaixo roda em dois jobs: test valida o backend e o frontend antes de
qualquer coisa; deploy só executa se o test passar
(needs: test), e conecta na VPS via SSH pra atualizar os containers.
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Testar backend (Spring Boot)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- run: mvn -f backend/pom.xml test
- name: Buildar frontend (Angular)
uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci --prefix frontend
- run: npm run build --prefix frontend -- --configuration production
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: |
cd /var/www/app
git pull origin main
docker compose up -d --build
docker image prune -f
test só valida que o Angular builda sem erro — quem efetivamente atualiza o
site é o git pull na VPS, seguido do rebuild do Nginx via
docker compose up -d --build, já que o dist/ é gerado
direto no servidor nesse modelo simples. Veja a seção 10.5 pra uma alternativa mais rápida.
10.5 Evolução: build de imagens no CI (opcional)
O fluxo acima builda o backend dentro da VPS a cada deploy, o que consome CPU/RAM da própria
máquina de produção. Um passo além é buildar a imagem Docker dentro do Actions Runner e publicar num registry
(ex: GitHub Container Registry — ghcr.io), deixando a VPS só responsável por baixar
a imagem pronta e reiniciar o container:
- No workflow, adicionar
docker/build-push-actionpra buildar e enviar a imagem praghcr.io/usuario/app-api:latest. - No
docker-compose.yml, trocarbuild: ./backendporimage: ghcr.io/usuario/app-api:latest. - No script SSH do job
deploy, trocar--buildpordocker compose pull && docker compose up -d.
Vale a pena a partir do momento em que o build na VPS começar a demorar demais ou disputar recursos com a aplicação rodando — pra um app pequeno, o fluxo da seção 10.4 já é suficiente.
11. Troubleshooting
| Sintoma | Causa provável | Solução |
|---|---|---|
NET::ERR_CERT_DATE_INVALID no navegador |
Certificado expirado — renovação automática falhou | Rode sudo certbot certificates; confirme que o authenticator é webroot, não standalone (ver seção 8) |
Could not bind TCP port 80 |
Certbot tentando abrir a porta 80 enquanto o Nginx (Docker) já está usando | Trocar de standalone pra webroot — reemitir com certonly --webroot ... --cert-name seudominio.com |
502 Bad Gateway |
Container da API fora do ar, ou nome errado no proxy_pass |
docker compose ps e docker compose logs api |
| Frontend não atualiza após novo build | Cache do navegador, ou pasta dist/ não sincronizada |
Hard refresh (Ctrl+Shift+R); confirme o caminho do bind mount no docker-compose.yml |
| Container reinicia sozinho em loop | Erro de inicialização (ex: variável de ambiente faltando) | docker compose logs --tail=100 <serviço> |
Workflow falha com Permission denied (publickey) |
Chave privada errada/incompleta no secret VPS_SSH_KEY, ou a chave pública não foi adicionada ao authorized_keys do usuário certo na VPS |
Recriar o secret colando a chave privada inteira (incluindo as linhas BEGIN/END); confirmar com ssh -i gh_actions_deploy deploy@SEU_IP localmente antes de cadastrar no GitHub |
git pull falha no workflow com conflito |
Alguém editou um arquivo versionado direto na VPS, fora do fluxo de Git | Verificar com git status na VPS; descartar a mudança local (git checkout -- .) ou commitá-la — nunca deixar a VPS com estado divergente do repositório |
12. Referência rápida
# containers
docker compose up -d --build # subir/atualizar tudo
docker compose ps # status
docker compose logs -f <serviço> # logs em tempo real
docker compose restart <serviço> # reiniciar um serviço
# nginx
docker exec app-nginx nginx -t # validar config
docker exec app-nginx nginx -s reload # recarregar sem downtime
# ssl
sudo certbot certificates # ver certificados e validade
sudo certbot renew --dry-run # testar renovação (não altera nada)
sudo systemctl list-timers | grep certbot # confirmar renovação automática ativa