Why self-host Spring Boot on a VPS
A Spring Boot application embeds its own server (Tomcat or Undertow) and ships as a standalone executable JAR. This is ideal for a VPS: a single app.jar file, one JVM, and you're running. PaaS platforms like Heroku or managed containers bill for every 512 MB slice of RAM, yet the JVM is greedy at startup. On a VPS, you set -Xmx yourself, you share the PostgreSQL database and the API on the same machine, and you control the cold warm-up without an imposed timeout. You also keep control over the exact JDK version (Temurin 21 LTS, GraalVM) and over the GC parameters, which is rarely possible in a managed environment.
Concrete benefits
- Full control of the JVM heap (
-Xmx,-Xms) and the garbage collector according to your real load - PostgreSQL database and API co-located: near-zero network latency between the app and the data
- Fixed and predictable monthly cost, with no billing per RAM slice or compute time
- JDK freedom: Temurin 21 LTS, GraalVM native-image or Azul Zulu according to your needs
- No imposed cold start: your service stays warm 24/7 for stable response times
- Actuator, Micrometer and Prometheus exposed freely for fine-grained monitoring at no extra cost
Hardware and software prerequisites
A Spring Boot API with a database runs comfortably on a VPS with 2 vCPU and 4 GB of RAM (count 1 to 1.5 GB for the JVM, the rest for PostgreSQL and the system). For a lightweight microservice without a heavy ORM, 2 GB is enough but stays tight at startup. Install Docker and Docker Compose, a JDK 21 if you compile on the server (otherwise build the JAR in CI and send only the artifact), and point a domain name such as api.mydomain.com at the VPS IP via an A record. Plan for 10 GB of disk minimum for the Docker images and logs.
Step-by-step deployment
Build the executable JAR
Run ./mvnw clean package -DskipTests (or ./gradlew bootJar). You get a file in target/app.jar that embeds Tomcat and all the dependencies. Check locally with java -jar target/app.jar before any upload.
Containerize with a multi-stage Dockerfile
Use eclipse-temurin:21-jre-alpine as the final image and copy only the JAR. A multi-stage build (maven:3.9-eclipse-temurin-21 to compile, JRE to run) reduces the image from 700 MB to about 250 MB. Expose port 8080.
Orchestrate app + PostgreSQL with Docker Compose
Declare two services in docker-compose.yml: app and db (image postgres:16). Pass the credentials via environment variables (SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME) and link them through an internal network. Run docker compose up -d.
Configure Nginx as a reverse proxy
Install Nginx and create a vhost that does a proxy_pass http://127.0.0.1:8080;. Add the X-Forwarded-For and X-Forwarded-Proto headers so that Spring correctly detects HTTPS behind the proxy (server.forward-headers-strategy=framework).
Enable HTTPS with Certbot
Run certbot --nginx -d api.mydomain.com to obtain and install a Let's Encrypt certificate. Automatic renewal is handled by Certbot's systemd timer. Force the HTTP-to-HTTPS redirect in the vhost.
Make restart reliable
Add restart: unless-stopped to your Compose services and configure the Actuator healthcheck (/actuator/health) as the Docker probe. This way the app restarts on its own after a VPS reboot or a JVM crash.
Compile your application into a native image with GraalVM (./mvnw -Pnative native:compile via Spring Boot 3): the binary starts in under 100 ms and consumes 3 to 5 times less RAM than a classic JVM. This is decisive on a small VPS where every hundred MB counts. Also limit the heap explicitly with JAVA_OPTS=-Xmx512m in Compose to prevent the JVM from claiming all the available memory.