Fazendo deploy de Spring Boot na Hetzner
Siga isso no seu próprio servidor. Comece grátis, sem cartão
O Spring Boot nunca convergiu pra um jeito adivinhável de construir e iniciar uma aplicação JVM do jeito que o Rails tem o Puma ou o Laravel tem o PHP-FPM: Maven ou Gradle, um jar puro ou um WAR, meia dúzia de comandos de run razoáveis dependendo de como o projeto está estruturado. Um Dockerfile contorna tudo isso dizendo exatamente qual JDK, qual ferramenta de build e qual comando final a aplicação precisa. Ao final deste guia você vai ter uma aplicação Spring Boot 4 construída a partir do próprio Dockerfile, rodando num servidor Hetzner por trás do Caddy com um certificado de verdade, apoiada pelo Postgres, com backups noturnos e um processo de release curto e repetível.
O que você vai precisar
- Uma conta Hetzner Cloud e um servidor. O CX23 otimizado por custo (2 vCPU, 4 GB de memória, 40 GB NVMe) está listado a €5,99/mês sem IVA; um build de Maven ou Gradle pode consumir muita memória, então se o próprio build for morto na metade do caminho, redimensione temporariamente pra um CX33 (4 vCPU, 8 GB, €8,99/mês) pro build e volte depois.
- Um nome de domínio ao qual você pode adicionar um registro de DNS.
- Um par de chaves SSH na sua máquina (
ssh-keygen -t ed25519se você ainda não tiver um). - Uma aplicação Spring Boot construída com Maven ou Gradle, visando Java 17 ou mais novo, o mínimo que o Spring Boot 4 exige. Este guia constrói uma nova a partir do Spring Initializr pra que você consiga acompanhar mesmo sem uma aplicação existente.
- Cerca de quinze minutos pro servidor e o DNS se acomodarem, a maior parte gasta esperando em vez de digitando.
Passo 1: criar o servidor e anotar o IP dele
No Hetzner Cloud Console, abra (ou crie) um projeto, depois escolha Add Server. Escolha uma localização, escolha Ubuntu 24.04 como imagem, depois em tipo de servidor escolha o vCPU compartilhada CX23. Em chaves SSH, adicione a chave pública da sua máquina, depois crie o servidor.
ssh root@YOUR_SERVER_IP
Você deve ver o banner de boas-vindas do Ubuntu e cair num shell root.
Passo 2: apontar seu domínio pro servidor
Adicione um registro A no seu provedor de DNS pro hostname que você quer usar, apontando pro IP do servidor:
Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_SERVER_IP
TTL: 300
dig +short myapp.example.com
Você deve ver o IP do seu servidor impresso de volta antes de seguir, já que o Caddy precisa disso já resolvendo no Passo 7.
Passo 3: instalar o Docker no servidor
apt-get update
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
systemctl enable --now docker
ufw allow OpenSSH
ufw allow 80
ufw allow 443
ufw --force enable
docker --version
docker compose version
Você deve ver uma versão do Docker e uma versão do Compose impressas, não um erro de “comando não encontrado”.
Passo 4: gerar a aplicação e se vincular à $PORT
Na sua própria máquina, gere um projeto a partir do start.spring.io (ou curl): Maven, Java 21, Spring Boot 4.1.x, com as dependências Web e PostgreSQL Driver no mínimo.
curl https://start.spring.io/starter.tgz \
-d dependencies=web,postgresql,actuator \
-d javaVersion=21 -d bootVersion=4.1.2 -d type=maven-project \
-d name=myapp -d baseDir=myapp | tar -xzvf -
cd myapp
Todo método de build no Railyard, e a configuração manual deste guia, espera que a aplicação se vincule a qualquer porta que ela receba via $PORT. O Spring Boot lê isso através de server.port, que mapeia pra variável de ambiente SERVER_PORT através do relaxed binding do Spring, então nenhuma mudança de código é necessária, só defina ela como variável de ambiente no Passo 6. Adicione o endpoint de health do Actuator, que o healthcheck do Passo 6 usa:
management:
endpoint:
health:
show-details: never
endpoints:
web:
exposure:
include: health
Confirme que o layout do projeto está lá:
ls pom.xml src/main/java
Passo 5: escrever o Dockerfile
FROM maven:3.9.9-eclipse-temurin-21 AS build
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY --from=build /build/target/*.jar app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
O estágio de build copia o pom.xml primeiro e roda dependency:go-offline antes de copiar o resto do código-fonte, então o cache de camadas do próprio Docker pula o re-download de cada dependência quando só seu código Java mudou, não o pom.xml em si. O estágio de runtime parte de um JRE em vez de um JDK completo, já que nada em tempo de execução precisa de um compilador, e roda como um usuário app não-root em vez de root. Troque maven:3.9.9-eclipse-temurin-21 e pom.xml/mvn por gradle:9-jdk21 e build.gradle/gradle build se seu projeto usa Gradle no lugar; o formato de dois estágios é idêntico de qualquer forma.
Cheque que constrói:
docker build -t myapp .
Você deve ver BUILD SUCCESS do Maven no meio do caminho, e a camada final da imagem completar sem nenhum erro.
Passo 6: escrever o arquivo do Compose
Copie sua aplicação pro servidor (scp, rsync, ou um git clone), depois adicione compose.yml e um arquivo .env ao lado dele:
mkdir -p /opt/myapp
cat > /opt/myapp/.env <<'EOF'
POSTGRES_PASSWORD=a-long-random-password
EOF
chmod 600 /opt/myapp/.env
services:
app:
build: .
image: myapp:latest
restart: unless-stopped
env_file: .env
environment:
SERVER_PORT: "8080"
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/myapp
SPRING_DATASOURCE_USERNAME: myapp
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
depends_on:
db:
condition: service_healthy
expose:
- "8080"
db:
image: postgres:16.4-alpine
restart: unless-stopped
environment:
POSTGRES_USER: myapp
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: myapp
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myapp"]
interval: 5s
timeout: 5s
retries: 10
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
volumes:
db_data:
caddy_data:
caddy_config:
SPRING_DATASOURCE_PASSWORD e as outras variáveis SPRING_DATASOURCE_* são como o relaxed binding do Spring mapeia uma variável de ambiente pra propriedade spring.datasource.* equivalente, sem precisar editar o application.yml por ambiente. depends_on: condition: service_healthy evita que a aplicação inicie antes do Postgres de fato poder aceitar conexões. Cheque se é lido corretamente:
docker compose config
Passo 7: adicionar o Caddy e iniciar o stack
myapp.example.com {
reverse_proxy app:8080
}
docker compose up -d --build
docker compose ps
curl -I https://myapp.example.com/actuator/health
Você deve ver os três serviços Up (db como healthy), e o curl retornar HTTP/2 200 com {"status":"UP"} no corpo.
Passo 8: fazer backup do Postgres
cat > /opt/myapp/backup.sh <<'EOF'
#!/bin/sh
cd /opt/myapp
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
docker compose exec -T db pg_dump -U myapp myapp | gzip > /opt/myapp/backups/$TIMESTAMP.sql.gz
find /opt/myapp/backups -name '*.sql.gz' -mtime +14 -delete
EOF
mkdir -p /opt/myapp/backups
chmod +x /opt/myapp/backup.sh
(crontab -l 2>/dev/null; echo "0 3 * * * /opt/myapp/backup.sh") | crontab -
Rode uma vez à mão e cheque que funcionou: ./backup.sh && ls -lh /opt/myapp/backups. Você deve ver um arquivo .sql.gz com o timestamp de hoje. Copie esses arquivos pra fora do servidor também, pra que perder a máquina não signifique perder o backup também.
Passo 9: lançar uma atualização
git pull
docker compose build app
docker compose up -d app
Adicione uma migration do Flyway ou do Liquibase se seu schema muda entre releases; um spring.jpa.hibernate.ddl-auto puro em produção é mais uma coisa que este guia deliberadamente deixa de fora, já que deixar o Hibernate alterar um schema de produção na inicialização é um jeito real de perder dados numa migration ruim. Cheque que foi aplicada:
docker compose logs --tail=50 app
curl -I https://myapp.example.com/actuator/health
O caminho mais curto: fazer isso com o Railyard
Um repositório com o próprio Dockerfile é o primeiro método de build que o Railyard checa, antes do Railpack, antes do Nixpacks, antes de qualquer outra coisa, e pra um projeto JVM isso não é um fallback, é a ferramenta correta pra um formato de build com respostas válidas demais pra chutar com segurança. Suba um repositório com o Dockerfile do Passo 5, e o Railyard constrói e faz o deploy dele em cada push do mesmo jeito que os Passos 6 a 9 fazem à mão, com o Postgres adicionado como um addon gerenciado cuja connection string é injetada como variável em vez de copiada num arquivo de compose por você, e um servidor Hetzner provisionado direto pelo Railyard no tamanho que você escolher.
Leia mais em build methods, deploying from a repository, databases and backups, e comparações de custo de hospedagem.
Resolução de problemas
O build é morto sem erro claro. O Maven ou o Gradle resolvendo e compilando uma árvore de dependências real pode precisar de mais memória do que um CX23 tem livre durante docker compose up -d --build; redimensione temporariamente, ou adicione swap como um paliativo mais barato (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
A aplicação não consegue alcançar o Postgres, “Connection refused”. Confirme que SPRING_DATASOURCE_URL usa db como host, não localhost; dentro da rede do Compose, db é o único endereço que resolve pro container do Postgres.
Porta já em uso. O Apache ou Nginx padrão do Ubuntu pode já estar vinculado à 80 ou 443. ss -tlnp | grep -E ':80|:443' mostra o que está ocupando.
HTTPS não é emitido. docker compose logs caddy mostrando erros repetidos de certificado quase sempre significa que o DNS do Passo 2 ainda não propagou, ou aponta pro IP errado. Rode dig +short myapp.example.com de novo e compare.
O jar constrói mas o container encerra imediatamente. Cheque docker compose logs app procurando um stack trace na inicialização, mais frequentemente uma variável de ambiente faltando que o Spring esperava ou uma conexão com o Postgres que ele não conseguiu estabelecer antes do healthcheck do depends_on deveria ter bloqueado isso; confirme que o healthcheck no Passo 6 realmente está passando com docker compose ps.
Perguntas frequentes
Eu preciso usar Maven, ou o Gradle funciona do mesmo jeito? O Gradle funciona do mesmo jeito; troque a imagem do builder por gradle:9-jdk21 e as duas linhas RUN por gradle dependencyInsight e depois gradle build -x test, o formato de dois estágios no Passo 5 não muda além disso.
Um CX23 é suficiente pra produção? Pra uma API pequena a média com tráfego modesto, sim, uma vez que o próprio build é tratado separadamente da Resolução de problemas acima; o consumo de memória da própria JVM em repouso é a principal coisa a observar numa máquina de 4 GB, ajuste o -Xmx explicitamente se você adicionar mais serviços no mesmo servidor depois.
Por que Java 21 em vez de uma release mais nova? O Spring Boot 4 só exige Java 17 ou mais novo; 21 é a release LTS atual com o suporte mais amplo de imagem base e ferramentas, que é por isso que este guia fixa ela, não uma exigência rígida.
Como eu adiciono um job em segundo plano ou uma tarefa agendada? A própria anotação @Scheduled do Spring roda dentro do mesmo processo e não precisa de container separado; um worker genuinamente separado (consumindo uma fila, por exemplo) é um segundo serviço no compose.yml construído a partir do mesmo Dockerfile com um comando de entrypoint diferente.
Qual é a diferença real em relação a um deploy de Rails ou Laravel aqui? O formato (servidor, Dockerfile, Postgres, Caddy, backup, release) é idêntico; a única diferença real é que o Rails e o Laravel recebem a detecção de build do Railyard sem nenhum Dockerfile, enquanto o formato de build do Spring Boot é genuinamente variado demais pra chutar com segurança, então o Dockerfile aqui não é opcional do jeito que seria pros outros.
Essa é uma configuração completa de produção: um servidor, Docker Compose rodando sua aplicação e o Postgres, Caddy cuidando do HTTPS, um backup noturno, e um processo de release curto. Mantenha o Dockerfile, o compose.yml e o Caddyfile no seu repositório junto da aplicação, e todo deploy futuro é o mesmo git pull, build, restart do Passo 9.
Por que escolhem o Railyard para isso
- Sua própria conta de cloud. Faça deploy no DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, ou em qualquer servidor Linux via SSH, cobrado direto pelo seu provedor, sem markup.
- Um preço fixo por equipe. O Free é grátis para sempre: um servidor mais um servidor de banco de dados, três apps, sem cartão. O Pro custa US$19 por mês para até cinco servidores e apps ilimitados. O Team custa US$49 por mês com servidores ilimitados. Sem cobrança por app, por deploy, por assento ou por gigabyte.
- Templates de um clique. Faça deploy de apps open source, como bancos de dados, analytics e ferramentas de dev, no seu próprio servidor em um clique.
- Bancos de dados gerenciados. Postgres, MySQL e Redis com backups programados, verificados por restauração, e recuperação point-in-time.
- Push-to-deploy. Faça deploy a partir do GitHub, GitLab ou da CLI, com HTTPS automático e releases sem downtime.