Deploying Spring Boot to a VPS with Apache

SS Saurav Sitaula

From localhost to the real internet. Learn to deploy your Spring Boot API to a VPS, configure Apache as a reverse proxy, manage .htaccess for routing, set up SSL with Let's Encrypt, and keep your app running with systemd. The deployment journey nobody warns you about.

The localhost Bubble

After making my API production-ready, I had one small problem: it only ran on my laptop.

“Check out my API!” “Sure, what’s the URL?” “http://localhost:8080” ”…”

I needed to deploy this thing to a real server. A server with a real IP address. On the actual internet.

How hard could it be?

Narrator: Very. Very hard.

Choosing a VPS

After researching hosting options, I went with a VPS (Virtual Private Server). Why not Heroku or AWS Elastic Beanstalk?

  1. Cost: A $5/month VPS vs pay-per-use cloud pricing
  2. Learning: I wanted to understand what actually happens when you deploy
  3. Control: Full root access, install whatever I want
  4. Company standard: Our team used VPS servers with Apache

I picked a basic Ubuntu VPS with 1GB RAM and 25GB storage. Enough for a Spring Boot app and MySQL.

Step 1: SSH Into the Server

First time SSH-ing into a server felt like hacking in a movie.

ssh root@your-server-ip

Password prompt. I’m in. A blinking cursor on a black screen. Now what?

Step 2: Install Java

My Spring Boot app needs Java. The server has… nothing.

# Update package list
apt update

# Install Java 17 (or whatever version you need)
apt install openjdk-17-jdk

# Verify
java -version

Output:

openjdk version "17.0.1" 2021-10-19
OpenJDK Runtime Environment (build 17.0.1+12-Ubuntu)

One dependency down.

Step 3: Install MySQL

# Install MySQL
apt install mysql-server

# Secure the installation
mysql_secure_installation

# Create database and user
mysql -u root -p
CREATE DATABASE myapi_db;
CREATE USER 'myapi_user'@'localhost' IDENTIFIED BY 'strong_password_here';
GRANT ALL PRIVILEGES ON myapi_db.* TO 'myapi_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;

Don’t use root for your application. Create a dedicated user with limited permissions.

Step 4: Upload the JAR File

On my local machine:

# Build the JAR
./mvnw clean package -DskipTests

# Upload to server
scp target/myapi-1.0.0.jar root@your-server-ip:/opt/myapi/

Or use FileZilla if you prefer a GUI. No shame in that.

Step 5: Create a Configuration File

On the server, create the production config:

mkdir -p /opt/myapi
nano /opt/myapi/application-prod.properties
# Server
server.port=8080

# Database
spring.datasource.url=jdbc:mysql://localhost:3306/myapi_db
spring.datasource.username=myapi_user
spring.datasource.password=strong_password_here

# JWT
jwt.secret=your-super-long-production-secret-key-at-least-256-bits
jwt.expiration=3600000

# Logging
logging.level.root=INFO
logging.file.name=/var/log/myapi/app.log

Create the log directory:

mkdir -p /var/log/myapi

Step 6: Test Run

cd /opt/myapi
java -jar myapi-1.0.0.jar --spring.profiles.active=prod

Output:

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.7.0)

Started MyApiApplication in 4.532 seconds

It’s running! But if I close the terminal, it dies. And it’s on port 8080, not 80 or 443.

Step 7: Run as a Service with systemd

Create a service file:

nano /etc/systemd/system/myapi.service
[Unit]
Description=My Spring Boot API
After=syslog.target network.target mysql.service

[Service]
User=www-data
Group=www-data
WorkingDirectory=/opt/myapi
ExecStart=/usr/bin/java -jar /opt/myapi/myapi-1.0.0.jar --spring.profiles.active=prod
SuccessExitStatus=143
Restart=always
RestartSec=10

# Environment variables (alternative to properties file)
Environment="JAVA_OPTS=-Xmx512m"

[Install]
WantedBy=multi-user.target

Fix permissions:

chown -R www-data:www-data /opt/myapi
chown -R www-data:www-data /var/log/myapi

Enable and start:

# Reload systemd
systemctl daemon-reload

# Enable on boot
systemctl enable myapi

# Start the service
systemctl start myapi

# Check status
systemctl status myapi

Now the app:

  • Starts automatically on server reboot
  • Restarts if it crashes
  • Runs as a non-root user (security!)
  • Can be managed with systemctl start/stop/restart myapi

Step 8: Install Apache

Why Apache instead of just exposing port 8080?

  1. Port 80/443: Standard HTTP/HTTPS ports
  2. SSL termination: Apache handles HTTPS, Spring Boot gets plain HTTP
  3. Multiple apps: One server can host multiple domains
  4. Static files: Serve React build from Apache, API from Spring Boot
  5. Security: Apache is battle-tested for public exposure
apt install apache2

# Enable required modules
a2enmod proxy
a2enmod proxy_http
a2enmod rewrite
a2enmod ssl
a2enmod headers

# Restart Apache
systemctl restart apache2

Step 9: Configure Apache Virtual Host

Create a virtual host config:

nano /etc/apache2/sites-available/myapi.conf
<VirtualHost *:80>
    ServerName api.yourdomain.com
    
    # Redirect HTTP to HTTPS (after SSL is set up)
    # RewriteEngine On
    # RewriteCond %{HTTPS} off
    # RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]

    # Proxy to Spring Boot
    ProxyPreserveHost On
    ProxyPass / http://localhost:8080/
    ProxyPassReverse / http://localhost:8080/

    # Logging
    ErrorLog ${APACHE_LOG_DIR}/myapi_error.log
    CustomLog ${APACHE_LOG_DIR}/myapi_access.log combined
</VirtualHost>

Enable the site:

a2ensite myapi.conf
systemctl reload apache2

Now http://api.yourdomain.com forwards to your Spring Boot app on port 8080!

Step 10: The .htaccess Adventures

When I tried to deploy my React app on the same server, things got interesting.

The setup:

  • yourdomain.com → React app (static files)
  • yourdomain.com/api/* → Spring Boot API

Create .htaccess in the React app’s directory (/var/www/html):

RewriteEngine On

# Handle API requests - proxy to Spring Boot
RewriteCond %{REQUEST_URI} ^/api/(.*)$
RewriteRule ^api/(.*)$ http://localhost:8080/api/$1 [P,L]

# Handle React Router - serve index.html for all non-file routes
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]

But wait, .htaccess can’t do proxying by default! I needed to update the main Apache config:

nano /etc/apache2/sites-available/000-default.conf
<VirtualHost *:80>
    ServerName yourdomain.com
    DocumentRoot /var/www/html

    <Directory /var/www/html>
        Options Indexes FollowSymLinks
        AllowOverride All
        Require all granted
    </Directory>

    # API Proxy
    ProxyPreserveHost On
    ProxyPass /api http://localhost:8080/api
    ProxyPassReverse /api http://localhost:8080/api

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>

The AllowOverride All enables .htaccess processing. The ProxyPass rules handle API routing at the Apache level (more reliable than .htaccess for proxying).

Step 11: CORS Configuration Update

With Apache in front, the CORS config needed updating. The API now sees requests from Apache, not the browser directly.

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins(
                    "http://yourdomain.com",
                    "https://yourdomain.com",
                    "http://localhost:3000"  // Keep for local development
                )
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .allowedHeaders("*")
                .allowCredentials(true);
    }
}

Step 12: SSL with Let’s Encrypt

HTTP in 2020? Unacceptable. Let’s Encrypt provides free SSL certificates.

# Install Certbot
apt install certbot python3-certbot-apache

# Get certificate (follow the prompts)
certbot --apache -d yourdomain.com -d api.yourdomain.com

# Auto-renewal is set up automatically, but verify:
certbot renew --dry-run

Certbot automatically updates your Apache config to handle HTTPS. Magic.

After SSL, update the virtual host to redirect HTTP to HTTPS:

<VirtualHost *:80>
    ServerName yourdomain.com
    RewriteEngine On
    RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
</VirtualHost>

<VirtualHost *:443>
    ServerName yourdomain.com
    DocumentRoot /var/www/html
    
    SSLEngine on
    SSLCertificateFile /etc/letsencrypt/live/yourdomain.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/yourdomain.com/privkey.pem

    # ... rest of config
</VirtualHost>

Step 13: Firewall Setup

Only expose necessary ports:

# Allow SSH (don't lock yourself out!)
ufw allow 22

# Allow HTTP and HTTPS
ufw allow 80
ufw allow 443

# Enable firewall
ufw enable

# Check status
ufw status

Port 8080 is NOT exposed. Only Apache (80/443) is public. Spring Boot is internal only.

Deployment Script

After doing this manually a few times, I wrote a script:

#!/bin/bash
# deploy.sh

set -e  # Exit on error

echo "🚀 Starting deployment..."

# Variables
SERVER="root@your-server-ip"
APP_DIR="/opt/myapi"
JAR_NAME="myapi-1.0.0.jar"

# Build
echo "📦 Building JAR..."
./mvnw clean package -DskipTests

# Upload
echo "📤 Uploading to server..."
scp target/$JAR_NAME $SERVER:$APP_DIR/

# Restart
echo "🔄 Restarting service..."
ssh $SERVER "systemctl restart myapi"

# Wait for startup
echo "⏳ Waiting for startup..."
sleep 10

# Health check
echo "🏥 Checking health..."
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://api.yourdomain.com/actuator/health)

if [ $HTTP_STATUS -eq 200 ]; then
    echo "✅ Deployment successful!"
else
    echo "❌ Health check failed with status $HTTP_STATUS"
    ssh $SERVER "journalctl -u myapi -n 50"  # Show recent logs
    exit 1
fi
chmod +x deploy.sh
./deploy.sh

One command to build, upload, restart, and verify. Beautiful.

Monitoring the Logs

When things go wrong (and they will):

# Spring Boot logs
tail -f /var/log/myapi/app.log

# Or via journalctl
journalctl -u myapi -f

# Apache logs
tail -f /var/log/apache2/myapi_error.log
tail -f /var/log/apache2/myapi_access.log

Common Issues I Hit

1. “Connection Refused” on Proxy

Spring Boot wasn’t running. Check with:

systemctl status myapi
curl http://localhost:8080/actuator/health

2. Permission Denied on Log File

chown -R www-data:www-data /var/log/myapi

3. .htaccess Not Working

Make sure AllowOverride All is set in the Apache config, and mod_rewrite is enabled:

a2enmod rewrite
systemctl restart apache2

4. “AH01114: HTTP: failed to make connection to backend”

Usually means Spring Boot isn’t running or is on a different port. Verify with:

netstat -tlnp | grep 8080

5. SSL Certificate Renewal Failed

Check Certbot logs:

cat /var/log/letsencrypt/letsencrypt.log

Usually a DNS issue or port 80 blocked during renewal.

What I Wish I’d Known Earlier

  1. Test locally with the prod profile first. --spring.profiles.active=prod catches config issues before deployment.

  2. Keep port 8080 internal. Never expose Spring Boot directly. Always use a reverse proxy.

  3. systemd is your friend. Auto-restart, boot on startup, proper logging. Learn it.

  4. SSL is non-negotiable. Let’s Encrypt is free. There’s no excuse for HTTP in production.

  5. Deployment scripts save sanity. Manual deployments lead to mistakes. Automate early.

  6. Check logs in order. Apache logs first (is the request reaching?), then Spring Boot logs (is the app processing?).

The Journey Complete (For Real This Time)

My Spring Boot API was now:

  • Running on a real server
  • Behind Apache with proper routing
  • Secured with SSL
  • Starting on boot
  • Monitored with logs
  • Deployable with one command

When my React app called https://api.yourdomain.com/api/users, it actually worked. From anywhere. On any device. On the real internet.

That first successful production request? Chef’s kiss.


P.S. — Deployment is where most tutorials stop and real learning begins. Nothing teaches you Linux, networking, and debugging like a server that refuses to cooperate at 11 PM. But once you’ve done it once, you can do it forever. The mystery is gone. It’s just a computer, in a data center, running your code. And that’s pretty cool.

SS

Saurav Sitaula

Software Architect • Nepal

Back to all posts
Saurav.dev

© 2026 Saurav Sitaula.AstroNeoBrutalism