If you have been running a Spring Boot application on your local machine and wondering how to get it onto a VPS, the process is more straightforward than most tutorials make it look. You need a JDK, a way to keep the process alive after you close your SSH session, and a reverse proxy so the outside world can reach port 443 instead of whatever port your app binds to.
This guide walks through each piece on Ubuntu or Debian. If you are still deciding whether a VPS is the right move, When to Move from Shared Hosting to a VPS explains why persistent processes like Spring Boot require a VPS in the first place. If you already have a server but have not done the initial setup yet (creating a non-root user, enabling a firewall, setting up SSH keys), work through that first: set up a VPS for the first time. The VPS security checklist is also worth completing before you expose any application to the internet.
Pick a JDK and Install It
Spring Boot runs on any standards-compliant JDK, but you want a distribution that tracks security patches, publishes to the standard apt repositories, and does not require a commercial license. Three options come up repeatedly:
| Distribution | Package name | Notes |
|---|---|---|
| Eclipse Temurin (Adoptium) | temurin-21-jdk |
Community-governed, widely used, has its own apt repo |
| Amazon Corretto | java-21-amazon-corretto-jdk |
AWS-backed, includes its own apt repo |
| Ubuntu default | default-jdk |
Ships whatever OpenJDK version the OS packages, may lag behind |
Temurin is the one you will see most often in production outside of AWS. To install it, add the Adoptium apt repository and then install the package:
sudo apt update
sudo apt install -y wget apt-transport-https gpg
wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
| gpg --dearmor \
| sudo tee /etc/apt/trusted.gpg.d/adoptium.gpg > /dev/null
echo "deb https://packages.adoptium.net/artifactory/deb $(lsb_release -cs) main" \
| sudo tee /etc/apt/sources.list.d/adoptium.list
sudo apt update
sudo apt install -y temurin-21-jdk
Swap 21 for 25 (or whichever version your project targets). LTS releases like 21 and 25 are the safe choice for production since they receive security patches over a longer window.
Verify with java -version. Out of that single command, the two things worth confirming are the version number and that the runtime is 64-bit ("64-Bit Server VM").
Build and Transfer the JAR
Spring Boot's Maven and Gradle plugins produce a "fat JAR" that bundles the embedded web server and all dependencies into one file. Build it on your local machine (or in CI) rather than compiling on the VPS, which may not have enough RAM for a full build.
Maven:
./mvnw clean package -DskipTests
Gradle:
./gradlew bootJar
The output lands in target/ (Maven) or build/libs/ (Gradle). Transfer it to the server with scp or rsync:
scp target/myapp-0.0.1-SNAPSHOT.jar youruser@your-server-ip:/opt/myapp/myapp.jar
rsync is a better choice if you are deploying frequently, since it only transfers bytes that changed and can resume interrupted uploads: rsync -avz target/myapp-0.0.1-SNAPSHOT.jar youruser@your-server-ip:/opt/myapp/myapp.jar.
Creating a dedicated directory under /opt keeps things tidy and gives you a stable path for the systemd service file. If you are doing this regularly, automating the deployment with a CI/CD pipeline removes the manual scp step entirely.
Run It as a systemd Service
Starting your JAR with java -jar myapp.jar in an SSH session works for a quick test, but the process dies as soon as you disconnect. The right answer on any modern Linux distribution is a systemd unit file. It handles startup, restarts on failure, log capture, and environment variables.
Create /etc/systemd/system/myapp.service:
[Unit]
Description=My Spring Boot Application
After=network.target
[Service]
User=myappuser
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/myapp.jar
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
EnvironmentFile=/opt/myapp/myapp.env
[Install]
WantedBy=multi-user.target
A few things worth noting:
- Run under a dedicated user, not root. Create one with
sudo adduser --system --group myappuserand grant it read access to the JAR directory. If the application is compromised, the blast radius is limited to that user's permissions. EnvironmentFilekeeps secrets out of the unit file. Put your database URL, credentials, and Spring profile in/opt/myapp/myapp.env:
SPRING_PROFILES_ACTIVE=prod
SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/mydb
SPRING_DATASOURCE_USERNAME=mydbuser
SPRING_DATASOURCE_PASSWORD=changeme
Restrict the file permissions: sudo chmod 600 /opt/myapp/myapp.env and sudo chown myappuser:myappuser /opt/myapp/myapp.env.
Restart=on-failurewith a 10-second delay means systemd relaunches the app if it crashes, but not if you stop it deliberately withsystemctl stop.
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
Check the logs with journalctl -u myapp -f. You should see the usual Spring Boot startup banner and the "Started MyApplication in X seconds" line.
Put Nginx in Front as a Reverse Proxy
Your application is now listening on localhost:8080 (or whatever server.port you configured). Visitors need to reach it on port 443 with a valid TLS certificate. Nginx handles both jobs.
Install Nginx if it is not already present:
sudo apt install -y nginx
Create a site configuration at /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Enable it and test the configuration:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
For SSL, use Certbot with the Nginx plugin. The full details are covered in the Let's Encrypt guide, but the short version is:
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Certbot modifies the Nginx config to add the listen 443 ssl block, point to the certificate files, and redirect HTTP to HTTPS. It also sets up automatic renewal via a systemd timer.
For a deeper dive into the proxy configuration itself (timeouts, buffer tuning, WebSocket support, load balancing), see the Nginx reverse proxy guide.
Tell Spring Boot It Is Behind a Proxy
Spring Boot needs to know the original client IP and protocol, otherwise redirects and generated URLs will point at http://127.0.0.1:8080. Add this to your production properties:
server.forward-headers-strategy=native
This trusts the X-Forwarded-* headers that Nginx sends. The native strategy uses the embedded Tomcat's built-in remote IP valve. On a single-server setup where only Nginx talks to localhost:8080, this is sufficient and safe.
JVM Tuning for Limited RAM
Java has a reputation for being memory-hungry, and on a VPS with 1 or 2 GB of total RAM, it is not an unfair concern. The JVM allocates a heap, plus metaspace, thread stacks, and native memory for the garbage collector. Left to its defaults, a modern JVM claims up to a quarter of total system memory for the heap alone, which on a small VPS can crowd out the operating system's disk caches and other services.
For a detailed breakdown of how much RAM various workloads need (including Spring Boot), see How Much RAM Does Your VPS Actually Need. The short version for a single-app VPS: set the heap explicitly so the JVM does not guess.
Update the ExecStart line in your systemd service file:
ExecStart=/usr/bin/java \
-Xms256m -Xmx512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-jar /opt/myapp/myapp.jar
What each flag does:
-Xms256m -Xmx512m: sets the initial and maximum heap. For a typical Spring Boot app with a moderate number of endpoints and a connection pool, 256 to 512 MB of heap is a reasonable starting point on a 2 GB VPS. If you are running a database on the same server, keep the heap lower and leave room for the database buffer pool.-XX:+UseG1GC: the G1 garbage collector is the default on JDK 17 and later, so this flag is technically redundant on current versions. Including it makes the choice explicit and visible in the service file.-XX:MaxGCPauseMillis=200: a soft target for GC pause duration. G1 will adjust its work to try to stay under this threshold.
A few other flags you may see in VPS deployment guides:
| Flag | What it does | When to use it |
|---|---|---|
-XX:+UseStringDeduplication |
Lets G1 deduplicate identical String values on the heap |
Useful if your app stores large numbers of repeated strings (caching, report generation) |
-XX:+ExitOnOutOfMemoryError |
Kills the JVM immediately on OOM instead of limping along | Recommended for production; lets systemd restart a clean process |
-XX:MaxMetaspaceSize=128m |
Caps metaspace growth | Prevents a class-loading leak from eating all native memory |
After changing JVM flags, reload and restart:
sudo systemctl daemon-reload
sudo systemctl restart myapp
Monitor actual memory usage with ps -o rss,vsz,comm -p $(pgrep -f myapp.jar). The RSS (resident set size) column is what your app is actually using. Compare it against the output of free -h to confirm you have breathing room for the OS and other services.
Deployment Checklist
Before you consider the application "live," run through this:
systemctl status myappshows "active (running)" with no recent restart loopsjournalctl -u myapp --since "10 minutes ago"shows a clean startup, no stack tracescurl -I https://yourdomain.comreturns a200with the correctContent-Type- The TLS certificate is valid:
sudo certbot certificateslists your domain with an expiry date in the future - The firewall allows only ports 22, 80, and 443:
sudo ufw status - The environment file
/opt/myapp/myapp.envis owned bymyappuserwith600permissions
What About Docker?
Everything above deploys a JAR directly on the host OS. If you prefer containerised deployments, where the JDK, dependencies, and application are bundled into a Docker image, see How to Run Docker Containers on a VPS. Docker adds a layer of isolation and makes it easier to run multiple applications on the same server, but it also consumes more memory (the JVM inside the container, plus Docker's overhead) and requires familiarity with container networking. For a single Spring Boot app on a small VPS, the direct JAR approach is simpler and uses fewer resources.
Moving Forward
A Spring Boot application on a VPS does not require a complex orchestration layer. A JDK, a systemd service, and Nginx in front give you a production-grade setup that starts on boot, restarts on failure, terminates TLS, and logs to the journal.
If your application outgrows a single server, or you want zero-downtime deploys and blue-green rollouts, the next step is typically a CI/CD pipeline that builds, tests, and ships your JAR automatically. But for most small-to-midsize applications, the setup in this guide is all you need.
Check the VPS provider directory to find a plan with enough memory for your workload, and read through the community reviews to see how others rate the providers they run Java applications on.