← Blog

Spring Boot auf Hetzner deployen

Railyard

24. September 2026 · 8 Min. Lesezeit

Folge dem auf deinem eigenen Server. Kostenlos starten, keine Karte nötig

Spring Boot hat sich nie auf einen einzigen, erratbaren Weg festgelegt, eine JVM-App zu bauen und zu starten, wie Rails Puma hat oder Laravel PHP-FPM: Maven oder Gradle, ein einfaches Jar oder ein WAR, ein halbes Dutzend plausibler Run-Befehle je nach Projektaufbau. Ein Dockerfile umgeht das alles, indem es genau sagt, welches JDK, welches Build-Tool und welchen finalen Befehl die App braucht. Am Ende dieser Anleitung hast du eine Spring-Boot-4-App, gebaut aus ihrem eigenen Dockerfile, die auf einem Hetzner-Server hinter Caddy mit einem echten Zertifikat läuft, mit Postgres im Rücken, mit nächtlichen Backups und einem kurzen, wiederholbaren Release-Prozess.

Was du brauchst

  • Ein Hetzner-Cloud-Konto und einen Server. Der kostenoptimierte CX23 (2 vCPU, 4 GB Speicher, 40 GB NVMe) kostet ab 5,99 €/Monat exkl. MwSt.; ein Maven- oder Gradle-Build kann speicherhungrig sein, also wenn der Build selbst mittendrin abgebrochen wird, skaliere vorübergehend auf einen CX33 (4 vCPU, 8 GB, 8,99 €/Monat) für den Build und danach wieder runter.
  • Einen Domainnamen, bei dem du einen DNS-Eintrag hinzufügen kannst.
  • Ein SSH-Schlüsselpaar auf deinem Rechner (ssh-keygen -t ed25519, falls du noch keins hast).
  • Eine mit Maven oder Gradle gebaute Spring-Boot-App, die auf Java 17 oder neuer zielt, das Minimum, das Spring Boot 4 verlangt. Diese Anleitung baut eine frische App mit Spring Initializr, sodass du auch ohne bestehende App mitmachen kannst.
  • Etwa fünfzehn Minuten, bis sich Server und DNS eingependelt haben, hauptsächlich Warten statt Tippen.

Schritt 1: Den Server erstellen und seine IP notieren

Öffne (oder erstelle) in der Hetzner Cloud Console ein Projekt und wähle dann Add Server. Wähle einen Standort, wähle Ubuntu 24.04 als Image, und wähle dann unter Server-Typ den Shared-vCPU-CX23. Füge unter SSH-Schlüssel den öffentlichen Schlüssel von deinem Rechner hinzu, und erstelle dann den Server.

ssh root@YOUR_SERVER_IP

Du solltest das Ubuntu-Willkommensbanner sehen und in einer Root-Shell landen.

Schritt 2: Deine Domain auf den Server zeigen lassen

Füge bei deinem DNS-Anbieter einen A-Record für den gewünschten Hostnamen hinzu, der auf die IP des Servers zeigt:

Type: A
Name: myapp (oder @ für die nackte Domain)
Value: YOUR_SERVER_IP
TTL: 300
dig +short myapp.example.com

Du solltest die IP deines Servers zurückgemeldet sehen, bevor du weitermachst, da Caddy das in Schritt 7 bereits aufgelöst braucht.

Schritt 3: Docker auf dem Server installieren

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

Du solltest eine Docker-Version und eine Compose-Version ausgegeben sehen, keinen “command not found”-Fehler.

Schritt 4: Die App generieren und $PORT binden

Generiere auf deinem eigenen Rechner ein Projekt über start.spring.io (oder curl): Maven, Java 21, Spring Boot 4.1.x, mit mindestens den Abhängigkeiten Web und 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

Jede Build-Methode bei Railyard, und das manuelle Setup dieser Anleitung, erwartet, dass die App an den Port bindet, der ihr über $PORT mitgeteilt wird. Spring Boot liest das über server.port, was über Springs flexibles Property-Binding auf die Umgebungsvariable SERVER_PORT abgebildet wird, sodass keine Codeänderung nötig ist, sondern nur eine Umgebungsvariable in Schritt 6. Füge den Actuator-Health-Endpunkt hinzu, den der Healthcheck in Schritt 6 nutzt:

management:
  endpoint:
    health:
      show-details: never
  endpoints:
    web:
      exposure:
        include: health

Prüfe, dass die Projektstruktur da ist:

ls pom.xml src/main/java

Schritt 5: Das Dockerfile schreiben

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"]

Die Build-Stage kopiert zuerst pom.xml und führt dependency:go-offline aus, bevor der restliche Quellcode kopiert wird, sodass Dockers eigener Layer-Cache das erneute Herunterladen jeder Abhängigkeit überspringt, wenn sich nur dein Java-Code geändert hat, nicht pom.xml selbst. Die Runtime-Stage startet von einer JRE statt einem vollen JDK, da zur Laufzeit kein Compiler nötig ist, und läuft als Non-Root-Nutzer app statt als root. Tausche maven:3.9.9-eclipse-temurin-21 und pom.xml/mvn gegen gradle:9-jdk21 und build.gradle/gradle build, falls dein Projekt Gradle statt Maven nutzt; die Form des zweistufigen Builds bleibt in beiden Fällen identisch.

Prüfe, dass er baut:

docker build -t myapp .

Du solltest irgendwo mittendrin BUILD SUCCESS von Maven sehen, und den finalen Image-Layer fehlerfrei abschließen.

Schritt 6: Die Compose-Datei schreiben

Kopiere deine App auf den Server (scp, rsync, oder ein git clone), und füge dann compose.yml und eine .env-Datei daneben hinzu:

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 und die anderen SPRING_DATASOURCE_*-Variablen sind, wie Springs flexibles Binding eine Umgebungsvariable auf die entsprechende spring.datasource.*-Property abbildet, ohne eine application.yml pro Umgebung zu bearbeiten. depends_on: condition: service_healthy verhindert, dass die App startet, bevor Postgres tatsächlich Verbindungen annehmen kann. Prüfe, dass es parst:

docker compose config

Schritt 7: Caddy hinzufügen und den Stack booten

myapp.example.com {
	reverse_proxy app:8080
}
docker compose up -d --build
docker compose ps
curl -I https://myapp.example.com/actuator/health

Du solltest alle drei Services als Up sehen (db als healthy), und curl sollte HTTP/2 200 mit {"status":"UP"} im Body zurückgeben.

Schritt 8: Postgres sichern

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 -

Führe es einmal manuell aus und prüfe, ob es funktioniert hat: ./backup.sh && ls -lh /opt/myapp/backups. Du solltest eine .sql.gz-Datei mit dem heutigen Zeitstempel sehen. Kopiere diese auch vom Server weg, damit der Verlust der Box nicht auch den Verlust des Backups bedeutet.

Schritt 9: Ein Upgrade verschicken

git pull
docker compose build app
docker compose up -d app

Füge eine Flyway- oder Liquibase-Migration hinzu, falls sich dein Schema zwischen Releases ändert; ein blankes spring.jpa.hibernate.ddl-auto in Produktion ist eine weitere Sache, die diese Anleitung absichtlich auslässt, da Hibernate ein Produktionsschema beim Boot ändern zu lassen ein echter Weg ist, Daten durch eine fehlerhafte Migration zu verlieren. Prüfe, ob es angekommen ist:

docker compose logs --tail=50 app
curl -I https://myapp.example.com/actuator/health

Die Abkürzung: mit Railyard

Ein Repo mit eigenem Dockerfile ist die erste Build-Methode, die Railyard prüft, vor Railpack, vor Nixpacks, vor allem anderen, und für ein JVM-Projekt ist das kein Fallback, sondern das richtige Werkzeug für eine Build-Form mit zu vielen gültigen Antworten, um sicher zu raten. Push ein Repo mit dem Dockerfile aus Schritt 5, und Railyard baut und deployt es bei jedem Push auf die gleiche Weise, wie Schritt 6 bis Schritt 9 es von Hand tun, mit Postgres als gemanagtem Add-on hinzugefügt, dessen Connection-String als Variable injiziert statt selbst in eine Compose-Datei kopiert wird, und einem Hetzner-Server, der direkt von Railyard in der von dir gewählten Größe provisioniert wird.

Mehr dazu in Build-Methoden, Deployment aus einem Repository, Datenbanken und Backups, und Hosting-Kostenvergleichen.

Troubleshooting

Der Build wird ohne klaren Fehler abgebrochen. Maven oder Gradle, die einen echten Abhängigkeitsbaum auflösen und kompilieren, können mehr freien Speicher brauchen, als ein CX23 während docker compose up -d --build hat; skaliere vorübergehend hoch, oder füge als günstigere Zwischenlösung Swap hinzu (fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile).

Die App erreicht Postgres nicht, “Connection refused”. Bestätige, dass SPRING_DATASOURCE_URL db als Host verwendet, nicht localhost; innerhalb von Compose’ Netzwerk ist db die einzige Adresse, die zum Postgres-Container aufgelöst wird.

Port bereits belegt. Ubuntus Standard-Apache oder -Nginx könnte bereits an 80 oder 443 gebunden sein. ss -tlnp | grep -E ':80|:443' zeigt, was den Port belegt.

HTTPS wird nicht ausgestellt. Wenn docker compose logs caddy wiederholte Zertifikatsfehler zeigt, bedeutet das fast immer, dass sich DNS aus Schritt 2 noch nicht propagiert hat oder auf die falsche IP zeigt. Führe dig +short myapp.example.com erneut aus und vergleiche.

Das Jar baut, aber der Container beendet sich sofort. Prüfe docker compose logs app auf einen Stacktrace beim Boot, meist eine fehlende Umgebungsvariable, die Spring erwartet hat, oder eine Postgres-Verbindung, die nicht hergestellt werden konnte, bevor der Healthcheck von depends_on sie hätte blockieren sollen; bestätige mit docker compose ps, dass der Healthcheck aus Schritt 6 tatsächlich durchläuft.

FAQ

Muss ich Maven verwenden, oder funktioniert Gradle genauso? Gradle funktioniert genauso; tausche das Builder-Image gegen gradle:9-jdk21 und die beiden RUN-Zeilen gegen gradle dependencyInsight dann gradle build -x test, die zweistufige Form in Schritt 5 ändert sich sonst nicht.

Reicht ein CX23 für Produktion? Für eine kleine bis mittelgroße API mit moderatem Traffic, ja, sobald der Build selbst getrennt behandelt wird wie im Troubleshooting oben; der eigene Speicherbedarf der JVM im Leerlauf ist die Hauptsache, die auf einer 4-GB-Box zu beachten ist, stimme -Xmx explizit ab, falls du später weitere Services auf denselben Server packst.

Warum Java 21 statt eines neueren Releases? Spring Boot 4 verlangt nur Java 17 oder neuer; 21 ist das aktuelle LTS-Release mit der breitesten Basis-Image- und Tooling-Unterstützung, weshalb diese Anleitung darauf fixiert, nicht als harte Vorgabe.

Wie füge ich einen Background-Job oder Scheduled Task hinzu? Springs eigene @Scheduled-Annotation läuft innerhalb desselben Prozesses und braucht keinen separaten Container; ein wirklich separater Worker (der etwa eine Queue konsumiert) ist ein zweiter Service in compose.yml, gebaut aus demselben Dockerfile mit einem anderen Entrypoint-Befehl.

Was ist der tatsächliche Unterschied zu einem Rails- oder Laravel-Deployment hier? Die Form (Server, Dockerfile, Postgres, Caddy, Backup, Release) ist identisch; der eine echte Unterschied ist, dass Rails und Laravel beide Railyards Build-Erkennung ganz ohne Dockerfile bekommen, während Spring Boots Build-Form tatsächlich zu variabel ist, um sicher zu raten, sodass das Dockerfile hier nicht optional ist, wie es für jene wäre.

Das ist ein vollständiges Produktions-Setup: ein Server, Docker Compose, das deine App und Postgres ausführt, Caddy, das HTTPS übernimmt, ein nächtliches Backup und ein kurzer Release-Prozess. Behalte das Dockerfile, compose.yml und Caddyfile in deinem Repository neben der App, und jedes zukünftige Deployment ist derselbe git pull, Build, Restart aus Schritt 9.

Warum Leute sich dafür Railyard entscheiden

  • Dein eigenes Cloud-Konto. Deploye auf DigitalOcean, Hetzner, Vultr, AWS, OVHcloud oder jedem Linux-Server per SSH, direkt von deinem Anbieter abgerechnet, ohne Aufschlag.
  • Ein fester Preis pro Team. Free ist für immer kostenlos: ein Server plus ein Datenbankserver, drei Apps, keine Karte. Pro kostet 19 $ im Monat für bis zu fünf Server und unbegrenzte Apps. Team kostet 49 $ im Monat mit unbegrenzten Servern. Keine Kosten pro App, pro Deployment, pro Sitz oder pro Gigabyte.
  • Templates mit einem Klick. Deploye Open-Source-Apps, wie Datenbanken, Analytics und Dev-Tools, mit einem Klick auf deinem eigenen Server.
  • Verwaltete Datenbanken. Postgres, MySQL und Redis mit geplanten, wiederherstellungsgeprüften Backups und Point-in-Time-Recovery.
  • Push-to-Deploy. Deploye von GitHub, GitLab oder der CLI, mit automatischem HTTPS und Releases ohne Ausfallzeit.

Push jetzt eine Spring-Boot-App

Verschick dein eigenes Dockerfile, und Railyard baut und deployt es bei jedem Push auf Hetzner, ohne Rätselraten bei der Build-Erkennung.

Kostenloser Plan, keine Kreditkarte, und 14 Tage Pro für jedes neue Team. Ein fester Preis pro Team.