Deploying Spring Boot to a VPS with Apache
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?
- Cost: A $5/month VPS vs pay-per-use cloud pricing
- Learning: I wanted to understand what actually happens when you deploy
- Control: Full root access, install whatever I want
- 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?
- Port 80/443: Standard HTTP/HTTPS ports
- SSL termination: Apache handles HTTPS, Spring Boot gets plain HTTP
- Multiple apps: One server can host multiple domains
- Static files: Serve React build from Apache, API from Spring Boot
- 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
-
Test locally with the prod profile first.
--spring.profiles.active=prodcatches config issues before deployment. -
Keep port 8080 internal. Never expose Spring Boot directly. Always use a reverse proxy.
-
systemd is your friend. Auto-restart, boot on startup, proper logging. Learn it.
-
SSL is non-negotiable. Let’s Encrypt is free. There’s no excuse for HTTP in production.
-
Deployment scripts save sanity. Manual deployments lead to mistakes. Automate early.
-
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.
Saurav Sitaula
Software Architect • Nepal