Desplegar Spring Boot en Hetzner
Sigue esto en tu propio servidor. Empieza gratis, sin tarjeta
Spring Boot nunca convergió en una única forma adivinable de construir y arrancar una app JVM como sí lo hizo Rails con Puma o Laravel con PHP-FPM: Maven o Gradle, un jar simple o un WAR, media docena de comandos de arranque razonables según cómo esté organizado el proyecto. Un Dockerfile evita todo eso al decir exactamente qué JDK, qué herramienta de build y qué comando final necesita la app. Al final de esta guía tendrás una app Spring Boot 4 construida desde su propio Dockerfile, corriendo en un servidor Hetzner detrás de Caddy con un certificado real, respaldada por Postgres, con copias de seguridad nocturnas y un proceso de lanzamiento corto y repetible.
Lo que necesitas
- Una cuenta de Hetzner Cloud y un servidor. El CX23 de costo optimizado (2 vCPU, 4 GB de memoria, 40 GB NVMe) figura a 5,99 €/mes sin IVA; un build de Maven o Gradle puede consumir mucha memoria, así que si el build mismo se queda a la mitad, cambia temporalmente a un CX33 (4 vCPU, 8 GB, 8,99 €/mes) para el build y vuelve a bajar después.
- Un nombre de dominio al que puedas agregarle un registro DNS.
- Un par de llaves SSH en tu máquina (
ssh-keygen -t ed25519si todavía no tienes una). - Una app Spring Boot construida con Maven o Gradle, dirigida a Java 17 o más reciente, el mínimo que requiere Spring Boot 4. Esta guía construye una nueva desde Spring Initializr para que puedas seguirla incluso sin una app existente.
- Unos quince minutos para que el servidor y el DNS se asienten, la mayoría del tiempo esperando en vez de escribiendo.
Paso 1: crear el servidor y anotar su IP
En la Hetzner Cloud Console, abre (o crea) un proyecto, y luego elige Add Server. Elige una ubicación, elige Ubuntu 24.04 como imagen, y luego en el tipo de servidor elige el CX23 de vCPU compartida. En SSH keys, agrega la llave pública de tu máquina, y luego crea el servidor.
ssh root@YOUR_SERVER_IP
Deberías ver el banner de bienvenida de Ubuntu y llegar a una shell de root.
Paso 2: apuntar tu dominio al servidor
Agrega un registro A en tu proveedor de DNS para el nombre de host que quieras usar, apuntando a la IP del servidor:
Type: A
Name: myapp (o @ para el dominio sin subdominio)
Value: YOUR_SERVER_IP
TTL: 300
dig +short myapp.example.com
Deberías ver la IP de tu servidor impresa de vuelta antes de seguir, ya que Caddy necesita que esto ya resuelva en el Paso 7.
Paso 3: instalar Docker en el 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
Deberías ver una versión de Docker y una versión de Compose impresas, no un error de “command not found”.
Paso 4: generar la app y enlazar $PORT
En tu propia máquina, genera un proyecto desde start.spring.io (o curl): Maven, Java 21, Spring Boot 4.1.x, con al menos las dependencias Web y PostgreSQL Driver.
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 en Railyard, y la configuración manual de esta guía, esperan que la app se enlace al puerto que se le indique a través de $PORT. Spring Boot lee eso mediante server.port, que se mapea a la variable de entorno SERVER_PORT gracias al enlace flexible de Spring, así que no se necesita ningún cambio de código, solo definirla como variable de entorno en el Paso 6. Agrega el endpoint de salud de Actuator, que usa el healthcheck del Paso 6:
management:
endpoint:
health:
show-details: never
endpoints:
web:
exposure:
include: health
Verifica que la estructura del proyecto esté ahí:
ls pom.xml src/main/java
Paso 5: escribir el 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"]
La etapa de build copia pom.xml primero y ejecuta dependency:go-offline antes de copiar el resto del código fuente, para que el propio caché de capas de Docker se salte la redescarga de cada dependencia cuando solo cambió tu código Java, no pom.xml en sí. La etapa de ejecución parte de un JRE en lugar de un JDK completo, ya que nada en tiempo de ejecución necesita un compilador, y corre como un usuario app que no es root en lugar de root. Reemplaza maven:3.9.9-eclipse-temurin-21 y pom.xml/mvn por gradle:9-jdk21 y build.gradle/gradle build si tu proyecto usa Gradle en lugar de Maven; la forma del build de dos etapas es idéntica de cualquier forma.
Verifica que construya:
docker build -t myapp .
Deberías ver BUILD SUCCESS de Maven a mitad de camino, y la capa final de la imagen completarse sin error.
Paso 6: escribir el archivo Compose
Copia tu app al servidor (scp, rsync, o un git clone), y luego agrega compose.yml y un archivo .env al lado:
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 y las demás variables SPRING_DATASOURCE_* son cómo el enlace flexible de Spring mapea una variable de entorno a la propiedad spring.datasource.* equivalente, sin editar application.yml por entorno. depends_on: condition: service_healthy evita que la app arranque antes de que Postgres realmente pueda aceptar conexiones. Verifica que se analice:
docker compose config
Paso 7: agregar Caddy y levantar la pila
myapp.example.com {
reverse_proxy app:8080
}
docker compose up -d --build
docker compose ps
curl -I https://myapp.example.com/actuator/health
Deberías ver los tres servicios en estado Up (y db como healthy), y el curl devolver HTTP/2 200 con {"status":"UP"} en el cuerpo.
Paso 8: respaldar 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 -
Ejecútalo una vez a mano y verifica que haya funcionado: ./backup.sh && ls -lh /opt/myapp/backups. Deberías ver un archivo .sql.gz con la marca de tiempo de hoy. Copia estos archivos fuera del servidor también, para que perder el servidor no signifique también perder el respaldo.
Paso 9: lanzar una actualización
git pull
docker compose build app
docker compose up -d app
Agrega una migración de Flyway o Liquibase si el esquema de tu base de datos cambia entre lanzamientos; un simple spring.jpa.hibernate.ddl-auto en producción es algo que esta guía deja fuera a propósito, ya que dejar que Hibernate altere un esquema de producción al arrancar es una forma real de perder datos con una mala migración. Verifica que haya quedado bien:
docker compose logs --tail=50 app
curl -I https://myapp.example.com/actuator/health
El atajo: hazlo con Railyard
Un repositorio con su propio Dockerfile es el primer método de build que revisa Railyard, antes de Railpack, antes de Nixpacks, antes que cualquier otra cosa, y para un proyecto JVM eso no es una opción de respaldo, es la herramienta correcta para una forma de build con demasiadas respuestas válidas como para adivinar con seguridad. Envía un repositorio con el Dockerfile del Paso 5, y Railyard lo construye y despliega en cada push de la misma forma en que lo hacen a mano el Paso 6 al Paso 9, con Postgres agregado como complemento gestionado cuya cadena de conexión se inyecta como variable en lugar de copiarla tú mismo en un archivo compose, y un servidor Hetzner aprovisionado directamente desde Railyard en el tamaño que elijas.
Lee más en métodos de build, desplegar desde un repositorio, bases de datos y copias de seguridad, y comparaciones de costos de hospedaje.
Solución de problemas
El build se termina sin un error claro. Maven o Gradle resolviendo y compilando un árbol de dependencias real puede necesitar más memoria de la que tiene libre un CX23 durante docker compose up -d --build; cambia temporalmente a uno más grande, o agrega swap como solución más barata (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
La app no puede llegar a Postgres, “Connection refused”. Confirma que SPRING_DATASOURCE_URL use db como host, no localhost; dentro de la red de Compose, db es la única dirección que resuelve hacia el contenedor de Postgres.
El puerto ya está en uso. El Apache o Nginx por defecto de Ubuntu puede que ya esté enlazado al 80 o 443. ss -tlnp | grep -E ':80|:443' muestra qué lo está ocupando.
El HTTPS no se emite. Que docker compose logs caddy muestre errores de certificado repetidos casi siempre significa que el DNS del Paso 2 no se ha propagado, o apunta a la IP equivocada. Vuelve a ejecutar dig +short myapp.example.com y compara.
El jar se construye pero el contenedor se cierra de inmediato. Revisa docker compose logs app en busca de un stack trace al arrancar, lo más común es una variable de entorno que Spring esperaba y que falta, o una conexión a Postgres que no pudo establecer antes de que el healthcheck de depends_on debiera haberlo bloqueado; confirma que el healthcheck del Paso 6 realmente esté pasando con docker compose ps.
Preguntas frecuentes
¿Tengo que usar Maven, o Gradle funciona de la misma forma? Gradle funciona igual; reemplaza la imagen de build por gradle:9-jdk21 y las dos líneas RUN por gradle dependencyInsight y luego gradle build -x test, la forma de dos etapas del Paso 5 no cambia por lo demás.
¿Un CX23 alcanza para producción? Para una API de tamaño pequeño a mediano con tráfico moderado, sí, una vez que el build en sí se maneja por separado como se explica en Solución de problemas; el propio consumo de memoria de la JVM en reposo es lo principal que hay que vigilar en una máquina de 4 GB, ajusta -Xmx explícitamente si agregas más servicios al mismo servidor más adelante.
¿Por qué Java 21 en lugar de una versión más nueva? Spring Boot 4 solo requiere Java 17 o más reciente; 21 es la versión LTS actual con el soporte más amplio de imágenes base y herramientas, por eso esta guía la fija, no porque sea un requisito estricto.
¿Cómo agrego una tarea en segundo plano o programada? La propia anotación @Scheduled de Spring corre dentro del mismo proceso y no necesita un contenedor aparte; un worker genuinamente separado (consumiendo una cola, por ejemplo) es un segundo servicio en compose.yml construido desde el mismo Dockerfile con un comando de entrada distinto.
¿Cuál es la diferencia real con un despliegue de Rails o Laravel aquí? La forma (servidor, Dockerfile, Postgres, Caddy, respaldo, lanzamiento) es idéntica; la única diferencia real es que tanto Rails como Laravel obtienen la detección de build de Railyard sin ningún Dockerfile, mientras que la forma de build de Spring Boot es realmente demasiado variada para adivinar con seguridad, así que el Dockerfile aquí no es opcional de la forma en que lo sería para esos.
Eso es una configuración de producción completa: un servidor, Docker Compose corriendo tu app y Postgres, Caddy manejando el HTTPS, un respaldo nocturno, y un proceso de lanzamiento corto. Guarda el Dockerfile, compose.yml y el Caddyfile en tu repositorio junto a la app, y cada futuro despliegue es el mismo git pull, build, reinicio del Paso 9.
Por qué eligen Railyard para esto
- Tu propia cuenta en la nube. Despliega en DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, o cualquier servidor Linux por SSH, facturado directamente por tu proveedor, sin márgenes adicionales.
- Un precio fijo por equipo. El plan Free es gratis para siempre: un servidor más un servidor de base de datos, tres apps, sin tarjeta. Pro cuesta $19 al mes por hasta cinco servidores y apps ilimitadas. Team cuesta $49 al mes con servidores ilimitados. Sin cargos por app, por despliegue, por asiento ni por gigabyte.
- Plantillas de un clic. Despliega apps de código abierto, como bases de datos, analítica y herramientas de desarrollo, en tu propio servidor con un clic.
- Bases de datos administradas. Postgres, MySQL y Redis, con copias de seguridad programadas y verificadas por restauración, y recuperación a un punto en el tiempo.
- Despliegue con git push. Despliega desde GitHub, GitLab o la CLI, con HTTPS automático y lanzamientos sin tiempo de inactividad.