← Blog

Fazendo deploy de Spring Boot na Hetzner

Railyard

24 de setembro de 2026 · 9 min de leitura

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 ed25519 se 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.

Suba uma aplicação Spring Boot agora mesmo

Envie seu próprio Dockerfile e o Railyard constrói e faz deploy dela na Hetzner em cada push, sem chute de detecção de build.

Plano grátis, sem cartão, e 14 dias de Pro para cada equipe nova. Um preço fixo por equipe.