Deploy Spring Boot to Hetzner
Follow this on your own server. Start free, no card needed
Spring Boot never converged on one guessable way to build and start a JVM app the way Rails has Puma or Laravel has PHP-FPM: Maven or Gradle, a plain jar or a WAR, half a dozen reasonable run commands depending on how a project’s laid out. A Dockerfile sidesteps all of that by saying exactly what JDK, what build tool and what final command the app needs. By the end of this guide you’ll have a Spring Boot 4 app built from its own Dockerfile, running on a Hetzner server behind Caddy with a real certificate, backed by Postgres, with nightly backups and a short, repeatable release process.
What you’ll need
- A Hetzner Cloud account and a server. The cost-optimized CX23 (2 vCPU, 4 GB memory, 40 GB NVMe) lists at €5.99/month excluding VAT; a Maven or Gradle build can be memory-hungry, so if the build itself gets killed partway through, resize up to a CX33 (4 vCPU, 8 GB, €8.99/month) for the build and back down afterward.
- A domain name you can add a DNS record to.
- An SSH key pair on your machine (
ssh-keygen -t ed25519if you don’t already have one). - A Spring Boot app built with Maven or Gradle, targeting Java 17 or newer, the minimum Spring Boot 4 requires. This guide builds a fresh one from Spring Initializr so you can follow along without an existing app.
- About fifteen minutes for the server and DNS to settle, mostly spent waiting rather than typing.
Step 1: Create the server and note its IP
In the Hetzner Cloud Console, open (or create) a project, then choose Add Server. Pick a location, choose Ubuntu 24.04 as the image, then under server type pick the Shared vCPU CX23. Under SSH keys, add the public key from your machine, then create the server.
ssh root@YOUR_SERVER_IP
You should see the Ubuntu welcome banner and land at a root shell.
Step 2: Point your domain at the server
Add an A record at your DNS provider for the hostname you want to use, pointing at the server’s IP:
Type: A
Name: myapp (or @ for the bare domain)
Value: YOUR_SERVER_IP
TTL: 300
dig +short myapp.example.com
You should see your server’s IP printed back before moving on, since Caddy needs this to already resolve in Step 7.
Step 3: Install Docker on the server
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
You should see a Docker version and a Compose version printed, not a “command not found” error.
Step 4: Generate the app and bind $PORT
On your own machine, generate a project from start.spring.io (or curl): Maven, Java 21, Spring Boot 4.1.x, with the Web and the PostgreSQL Driver dependencies at minimum.
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
Every build method on Railyard, and this guide’s manual setup, expects the app to bind whatever port it’s told to over $PORT. Spring Boot reads that through server.port, which maps to the environment variable SERVER_PORT through Spring’s relaxed binding, so no code change is needed, just set it as an environment variable in Step 6. Add the Actuator health endpoint, which Step 6’s healthcheck uses:
management:
endpoint:
health:
show-details: never
endpoints:
web:
exposure:
include: health
Verify the project layout is there:
ls pom.xml src/main/java
Step 5: Write the 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"]
The build stage copies pom.xml first and runs dependency:go-offline before copying the rest of the source, so Docker’s own layer cache skips re-downloading every dependency when only your Java code changed, not pom.xml itself. The runtime stage starts from a JRE instead of a full JDK, since nothing at runtime needs a compiler, and runs as a non-root app user rather than root. Swap maven:3.9.9-eclipse-temurin-21 and pom.xml/mvn for gradle:9-jdk21 and build.gradle/gradle build if your project uses Gradle instead; the shape of the two-stage build is identical either way.
Check it builds:
docker build -t myapp .
You should see BUILD SUCCESS from Maven partway through, and the final image layer complete with no error.
Step 6: Write the Compose file
Copy your app to the server (scp, rsync, or a git clone), then add compose.yml and a .env file next to it:
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 and the other SPRING_DATASOURCE_* variables are how Spring’s relaxed binding maps an environment variable to the equivalent spring.datasource.* property, without a application.yml edit per environment. depends_on: condition: service_healthy keeps the app from starting before Postgres can actually accept connections. Check it parses:
docker compose config
Step 7: Add Caddy and boot the stack
myapp.example.com {
reverse_proxy app:8080
}
docker compose up -d --build
docker compose ps
curl -I https://myapp.example.com/actuator/health
You should see all three services Up (db as healthy), and the curl return HTTP/2 200 with {"status":"UP"} in the body.
Step 8: Back up 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 -
Run it once by hand and check it worked: ./backup.sh && ls -lh /opt/myapp/backups. You should see a .sql.gz file with today’s timestamp. Copy these off the server too, so losing the box doesn’t also mean losing the backup.
Step 9: Ship an upgrade
git pull
docker compose build app
docker compose up -d app
Add a Flyway or Liquibase migration if your schema changes between releases; a bare spring.jpa.hibernate.ddl-auto in production is one more thing this guide deliberately leaves out, since letting Hibernate alter a production schema on boot is a real way to lose data on a bad migration. Check it landed:
docker compose logs --tail=50 app
curl -I https://myapp.example.com/actuator/health
The shortcut: do it with Railyard
A repo with its own Dockerfile is the first build method Railyard checks, before Railpack, before Nixpacks, before anything else, and for a JVM project that’s not a fallback, it’s the correct tool for a build shape with too many valid answers to guess safely. Push a repo with the Dockerfile from Step 5, and Railyard builds and deploys it on every push the same way Step 6 through Step 9 do by hand, with Postgres added as a managed add-on whose connection string gets injected as a variable instead of copied into a compose file yourself, and a Hetzner server provisioned directly from Railyard at the size you pick.
Read more in build methods, deploying from a repository, databases and backups, and hosting cost comparisons.
Troubleshooting
The build gets killed with no clear error. Maven or Gradle resolving and compiling a real dependency tree can need more memory than a CX23 has free during docker compose up -d --build; resize up temporarily, or add swap as a cheaper stopgap (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).
The app can’t reach Postgres, “Connection refused”. Confirm SPRING_DATASOURCE_URL uses db as the host, not localhost; inside Compose’s network, db is the only address that resolves to the Postgres container.
Port already in use. Ubuntu’s default Apache or Nginx may already be bound to 80 or 443. ss -tlnp | grep -E ':80|:443' shows what’s holding it.
HTTPS not issuing. docker compose logs caddy showing repeated certificate errors almost always means DNS from Step 2 hasn’t propagated, or points at the wrong IP. Re-run dig +short myapp.example.com and compare.
The jar builds but the container exits immediately. Check docker compose logs app for a stack trace on boot, most often a missing environment variable Spring expected or a Postgres connection it couldn’t establish before depends_on’s healthcheck should have blocked it; confirm the healthcheck in Step 6 is actually passing with docker compose ps.
FAQ
Do I have to use Maven, or does Gradle work the same way? Gradle works the same way; swap the builder image for gradle:9-jdk21 and the two RUN lines for gradle dependencyInsight then gradle build -x test, the two-stage shape in Step 5 doesn’t otherwise change.
Is a CX23 enough for production? For a small to mid-sized API with modest traffic, yes, once the build itself is handled separately from Troubleshooting above; the JVM’s own memory footprint at idle is the main thing to watch on a 4 GB box, tune -Xmx explicitly if you add more services to the same server later.
Why Java 21 instead of a newer release? Spring Boot 4 only requires Java 17 or newer; 21 is the current LTS release with the widest base image and tooling support, which is why it’s what this guide pins, not a hard requirement.
How do I add a background job or scheduled task? Spring’s own @Scheduled annotation runs inside the same process and needs no separate container; a genuinely separate worker (consuming a queue, say) is a second service in compose.yml built from the same Dockerfile with a different entrypoint command.
What’s the actual difference from a Rails or Laravel deploy here? The shape (server, Dockerfile, Postgres, Caddy, backup, release) is identical; the one real difference is that Rails and Laravel both get Railyard’s build detection without a Dockerfile at all, while Spring Boot’s build shape is genuinely too varied to guess safely, so the Dockerfile here isn’t optional the way it would be for those.
That’s a full production setup: a server, Docker Compose running your app and Postgres, Caddy handling HTTPS, a nightly backup, and a short release process. Keep the Dockerfile, compose.yml and Caddyfile in your repository alongside the app, and every future deploy is the same git pull, build, restart from Step 9.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.