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.

Navegador cliente HTTPS :443 Nginx container · :80 / :443 serve estático Angular (dist/) arquivos estáticos proxy_pass /api Spring Boot API container · :8080 JDBC :3306 MySQL container
Arquitetura: um único Nginx roteando entre frontend estático, API e banco — todos em containers na mesma rede Docker.

Ao longo do manual, substitua os placeholders abaixo pelos valores reais do seu projeto:

PlaceholderExemplo real
seudominio.comdomínio raiz do projeto
app.seudominio.comsubdomínio do frontend
api.seudominio.comsubdomínio da API
deployusuário SSH não-root na VPS
/var/www/appdiretório raiz do projeto na VPS
app-nginx, app-api, app-mysqlnomes 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:

bash · como root
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):

/etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
bash
sudo systemctl restart ssh
Antes de fechar a sessão Abra um segundo terminal e confirme que consegue logar como 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)

bash
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

bash
# 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):

estrutura de pastas
/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)

backend/Dockerfile
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:

.env
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
Segurança Adicione .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.

bash · local ou CI
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.

bash · exemplo de sync
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

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).

nginx/conf.d/default.conf
# 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;
    }
}
Por que 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:

TipoNomeValor
A@ (seudominio.com)IP da VPS
AwwwIP da VPS
AappIP da VPS
AapiIP da VPS

Propagação costuma levar de minutos a algumas horas. Confirme com:

bash
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.

Nginx (container) já escutando :80 Certbot --standalone tenta abrir :80 também Porta 80 do host só um processo pode usá-la ❌ Could not bind TCP port 80
Conflito: dois processos disputando a mesma porta 80. O Certbot standalone sempre perde enquanto o Nginx estiver rodando.
Sintoma no terminal 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.

Certbot (host) roda no host, sem Docker ./nginx/certbot-webroot pasta compartilhada escreve token Nginx (container) mesmo volume, via bind mount Let's Encrypt valida via HTTP GET /.well-known/... emite certificado
Certbot e Nginx compartilham um diretório em disco — o Certbot nunca precisa da porta 80 pra si mesmo.

8.3 Passo a passo

bash · 1. instalar certbot no host
sudo apt install -y certbot
bash · 2. criar a pasta compartilhada
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:

bash · 3. subir o Nginx
cd /var/www/app
docker compose up -d nginx
docker exec app-nginx nginx -t
bash · 4. emitir o certificado
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
bash · 5. recarregar o Nginx
docker exec app-nginx nginx -s reload
bash · 6. confirmar
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:

bash
sudo certbot renew --dry-run          # simula, não altera nada
sudo systemctl list-timers | grep certbot
Não precisa fazer nada manualmente Com o timer ativo e o método 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

bash
cd /var/www/app
docker compose up -d --build

9.2 Atualizar só o backend

bash
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:

bash
ng build --configuration production
rsync -avz --delete dist/app-frontend/ deploy@SEU_IP:/var/www/app/frontend/dist/app-frontend/

9.4 Logs

bash
docker compose logs -f api
docker compose logs -f nginx

9.5 Status dos containers

bash
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.

Desenvolvedor git push main GitHub dispara o workflow Actions Runner testa e builda SSH VPS
Push na branch principal → GitHub Actions roda testes/build → conecta via SSH na VPS → atualiza os containers.

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/app na VPS, com git remote configurado.
  • O arquivo .env configurado direto na VPS — ele nunca vai pro repositório, então não é tocado pelo deploy automático.
Não edite arquivos direto na VPS Se alguém alterar um arquivo versionado direto no servidor, o próximo 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:

bash · na sua máquina local
ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./gh_actions_deploy -N ""
bash · autorizar a chave pública na VPS (usuário deploy)
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 secretValor
VPS_HOSTIP ou domínio da VPS
VPS_USERdeploy
VPS_SSH_KEYconteúdo completo da chave privada gh_actions_deploy gerada acima
VPS_PORT22 (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.

.github/workflows/deploy.yml
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
Sobre o frontend nesse fluxo O job 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-action pra buildar e enviar a imagem pra ghcr.io/usuario/app-api:latest.
  • No docker-compose.yml, trocar build: ./backend por image: ghcr.io/usuario/app-api:latest.
  • No script SSH do job deploy, trocar --build por docker 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

SintomaCausa provávelSoluçã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

cheatsheet
# 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