Project 1 of 12 · project brief
Deploy Spring Boot on a Bare Linux Server
Ubuntu + Nginx + PostgreSQL + a Spring Boot service, installed and hardened by hand, the way everything worked before containers.
The scenario
ShopLite's first customer-facing API has to go live on a single Ubuntu VM, and the team wants someone who understands every layer before automating it. Your brief: get the Spring Boot API running as a proper service behind Nginx, with PostgreSQL on the same box, locked down, logging, and surviving reboots.
Doing this manually once is the fastest way to understand what Docker, Kubernetes, and Ansible automate later.
Before you start
- Linux course
Users, permissions, systemd, packages, logs, SSH, firewall.
- Networking · NGINX as a reverse proxy
The proxy configuration you'll write.
Stack
Target architecture
Deliverables and requirements
You will hand in
- A VM (local VirtualBox/Multipass or a cloud instance) serving the API at
http://<ip>/api/healththrough Nginx - A
shoplite-api.servicesystemd unit running as an unprivileged user, restarting on failure - PostgreSQL with a dedicated database and least-privilege app role (Stateful course, Unit 1.1)
- A
setup.mdrunbook with every command, and abackup.shcron job
Functional
- API reachable only through Nginx on port 80; port 8080 and 5432 not reachable from outside
- App starts automatically on boot and restarts if it crashes
- Database backup runs nightly with 7 days of retention
Non-functional
- SSH key-only login, root login disabled
- Firewall allows only 22 (from your IP) and 80
- App logs visible with
journalctl -u shoplite-api - No secrets in the unit file committed to Git (use an
EnvironmentFilewith 0600 permissions)
Milestones
- 1
Provision and secure the server
Done when: A patched Ubuntu VM you can SSH into with a key, with a firewall on.
- Create the VM; create a sudo user; install your SSH public key
- Disable password auth and root login in
sshd_config; restart ssh apt update && apt upgrade; enable unattended-upgrades- Enable ufw allowing only OpenSSH and 80
Stuck? Hints
- Keep a second SSH session open while changing sshd config so you can't lock yourself out.
sudo ufw allow from <your-ip> to any port 22is tighter than allowing OpenSSH from anywhere.
Prove it works
terminal$ sudo ufw status verbose; sudo sshd -T | grep -E 'passwordauthentication|permitrootlogin'── expected output ──Status: active...passwordauthentication nopermitrootlogin no - 2
Install PostgreSQL and create the app database
Done when: A
shopdatabase owned byshop_owner, with an app role limited to DML.- Install
postgresql - Create roles and database using the SQL from Stateful Systems Unit 1.1
- Confirm Postgres listens only on localhost (
listen_addresses)
Prove it works
terminal$ sudo ss -tlnp | grep 5432── expected output ──LISTEN 0 200 127.0.0.1:5432 ... postgres - Install
- 3
Run the app as a systemd service
Done when: The JAR runs as user
shoplite, reads config from an EnvironmentFile, and restarts on failure.- Install OpenJDK 21; create a system user
shoplitewith no login shell - Build the Spring Boot JAR (
./mvnw package) and copy it to/opt/shoplite/app.jar - Write
/etc/shoplite/api.env(DB URL, user, password) with mode 0600, owned by root:shoplite - Write
/etc/systemd/system/shoplite-api.servicewithUser=shoplite,EnvironmentFile=,Restart=on-failure, and memory limits
Stuck? Hints
- Add
ProtectSystem=strict,ReadWritePaths=/var/lib/shoplite, andNoNewPrivileges=truefor cheap hardening. systemctl daemon-reloadafter editing unit files.
Prove it works
terminal$ sudo systemctl enable --now shoplite-api && systemctl is-active shoplite-api && curl -s localhost:8080/actuator/health── expected output ──active{"status":"UP"} - Install OpenJDK 21; create a system user
- 4
Put Nginx in front
Done when: Nginx proxies port 80 to the app, with sensible timeouts and headers.
- Install nginx; write a site config proxying
/to127.0.0.1:8080 - Set
proxy_set_headerfor Host and X-Forwarded-For; add a/healthzlocation nginx -tthen reload
Prove it works
terminal$ curl -si http://<vm-ip>/actuator/health | head -1; curl -s --max-time 3 http://<vm-ip>:8080 || echo 'blocked (good)'── expected output ──HTTP/1.1 200 OKblocked (good) - Install nginx; write a site config proxying
- 5
Backups, logs, and a reboot test
Done when: Nightly
pg_dump, log rotation, and proof everything survives a reboot.- Write
backup.sh(pg_dump -Fc, keep 7 days) and a cron entry at 02:00 - Check journald retention (
SystemMaxUse) - Reboot and verify the site comes back without intervention
Prove it works
terminal$ sudo reboot # wait, reconnectcurl -s http://<vm-ip>/actuator/health && ls -1 /var/backups/shoplite | tail -2── expected output ──{"status":"UP"}shop-2026-09-26.dumpshop-2026-09-27.dump - Write
Would you run this in production?
- ☐SSH hardened, firewall minimal, automatic security updates on
- ☐App runs unprivileged with systemd hardening options
- ☐Secrets outside Git with strict file permissions
- ☐Backups automated AND a restore has been tested
- ☐Health endpoint monitored from outside (e.g. an uptime check)
Stretch goals
- Add HTTPS with a certificate from Let's Encrypt (certbot) and redirect HTTP to HTTPS
- Ship logs to a central place (Fluent Bit → OpenSearch or Loki)
- Turn your setup.md into an Ansible playbook (GitOps course, Stage 0)
Show it off
Résumé bullet
Deployed and hardened a Spring Boot + PostgreSQL service on Ubuntu with Nginx, systemd, ufw, and automated backups; documented a reproducible runbook.
Demo script
- Show
systemctl statusand a crash → auto-restart (kill -9the Java PID) - Show the firewall blocking 8080 and 5432 from outside
- Restore last night's backup into a scratch database
Interview questions about this project
Why run the app behind Nginx instead of exposing Spring Boot directly?
How do you make a Java app start on boot and restart on failure on Linux?