I'm Noah, currently Technology Director at LAPA and finishing my B.S. in Information Systems & Analytics at LSU. I've spent the past few years in tier 1 IT support at the LSU AgCenter—creating and managing service tickets, troubleshooting desktop issues, basic user support—but I've realized that's not where I want to end up. I'm aiming for cloud engineering and DevOps roles, and I've decided not to wait until after graduation to start building those skills. I've been building my technical foundation through my home lab (Active Directory, network configurations, virtualization) and certifications (CompTIA Network+ and Security+).
Rather than enrolling in an expensive bootcamp, I'm following TechWorld with Nana's free DevOps roadmap. She provides project descriptions and high-level guidance on her website, and her YouTube channel has been invaluable—I've learned more from her free videos about Docker, Kubernetes, and CI/CD than from entire college courses. I'm using her roadmap as a structured learning path while building everything from scratch myself. I'm documenting the entire process—mistakes, dead ends, and all—because I believe the struggles are where the real learning happens.
This is Project 1: Deploying a Java Gradle application to a cloud server. On the surface, this sounds simple: spin up a server, copy a JAR file, run it. But as I quickly discovered, there's a massive gap between "works on my laptop" and "runs in production."
The Goal (and What I Thought Would Happen)
The objective:
- Provision a cloud server from scratch
- Deploy a Java application
- Implement proper security (least privilege principle)
- Make it accessible via the internet
Coming from tier 1 IT support where most software is pre-packaged and "just works" after installation, I expected this to be straightforward. Download Java, copy the file, run it. Maybe an hour, tops.
It took me an entire afternoon, and I learned more about build tools, dependency management, and Linux security than I did in my last semester of classes.
Part 1: Provisioning the Server
I chose DigitalOcean because Nana's roadmap starts there before gradually transitioning to AWS. The interface is simpler and more straightforward for learning the fundamentals, and the $6/month droplet fit my student budget perfectly.
Server specs:
- Ubuntu 22.04 LTS
- 512 MB RAM
- 10 GB SSD
- 1 vCPU
- Region: San Francisco 3
SSH Key Setup (The Right Way)
Before even creating the server, I generated an SSH key pair on my laptop:
ssh-keygen -t rsa -b 4096 -C "noah@laptop"
This created two files in ~/.ssh/:
id_rsa– Private key (stays on my laptop, NEVER share this)id_rsa.pub– Public key (goes on the server)
I copied the contents of id_rsa.pub and pasted it into DigitalOcean's SSH key configuration during server creation. This is infinitely more secure than password authentication because:
- Passwords can be brute-forced — SSH keys can't (4096-bit RSA is computationally infeasible to crack)
- Passwords are typed — They can be keylogged or shoulder-surfed
- Passwords are reused — People use the same password everywhere (I know I used to)
Once the droplet was created, I got its public IP and connected:
ssh root@<droplet-ip>
No password prompt. Just straight in. First cloud server: ✅
Firewall Configuration (First Security Layer)
DigitalOcean has a built-in cloud firewall that sits outside the server itself. I configured it to only allow:
Inbound:
- SSH (port 22) from my home IP only
Outbound:
- All traffic allowed (so the server can download packages)
This is defense in depth. Even if someone compromises my SSH key, they can't connect unless they're coming from my IP address.
Through my Security+ studies and cyber risk management coursework at LSU, I've learned about how quickly exposed servers get compromised. SSH brute-force attacks are constant—I've read case studies of servers being breached within hours of being exposed to the internet. Rate limiting by IP is the first line of defense.
Part 2: Building the Application (Where Everything Broke)
The project uses a Java Gradle application from Nana's GitLab repository. I cloned it to my laptop, opened it in IntelliJ IDEA, and hit the build button.
FAILURE: Build failed with an exception.
* What went wrong:
A problem occurred evaluating root project.
> org/gradle/api/plugins/JavaPluginConvention
The Gradle 8 Compatibility Issue
I stared at this error for a solid 10 minutes. JavaPluginConvention? I'd never heard of it. I started Googling.
What I learned:
Gradle 8 removed an internal API called JavaPluginConvention that older versions of the Spring Boot Gradle plugin relied on. The application was using Spring Boot 2.7.11, which was built before Gradle 8 existed. The plugin expected this API to be there, and when Gradle 8 removed it, the build broke.
The fix: Downgrade to Gradle 7.6.4.
But here's the thing—I didn't know how to manage multiple Gradle versions. IntelliJ comes bundled with Gradle, but that's not what gets used when you run Gradle from the terminal.
Enter SDKMAN.
SDKMAN: The Tool I Didn't Know I Needed
SDKMAN is a version manager for Java development tools—Gradle, Maven, Java itself, even Kotlin and Scala. It lets you install multiple versions and switch between them on the fly.
# Install SDKMAN
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"
# Install Gradle 7.6.4
sdk install gradle 7.6.4
# Use it for this session
sdk use gradle 7.6.4
Different projects require different tool versions. In production, you might maintain legacy applications on Gradle 6, new microservices on Gradle 8, and third-party integrations on Gradle 7. SDKMAN lets you context-switch without constantly reinstalling tools.
I ran the build again.
Unsupported class file major version 65
The Java Version Rabbit Hole
This error message is cryptic if you've never seen it before. "Class file major version 65" is Java's way of saying "this bytecode was compiled with Java 21, but you're trying to run it with an older JVM that doesn't understand Java 21 bytecode."
But wait—I wasn't trying to run anything yet. I was just building the project. Why would the build process care about my Java version?
What I learned:
Gradle itself is a Java application. When you run gradle build, you're running Gradle with whatever Java version your system has. Gradle 7.6.4 doesn't support Java 21 because Java 21 introduced bytecode changes that Gradle 7 doesn't understand.
The project was configured for Java 17 (the LTS version), but I had Java 21 installed on my laptop—that was the latest version I had installed, and I didn't realize it would cause compatibility issues.
The fix: Install Java 17.
sdk install java 17.0.10-tem
sdk use java 17.0.10-tem
IntelliJ vs. System Java: A Critical Distinction
Here's where I made a mistake that wasted 20 minutes of my life.
I went into IntelliJ's project structure settings and changed the JDK to Java 17. Then I ran gradle build from my terminal.
Same error.
Why?
IntelliJ's JDK setting only affects IntelliJ itself—the IDE uses that JDK for code analysis, autocomplete, and running code within the IDE. When you run Gradle from the terminal, it uses whatever java command exists in your system PATH.
These are completely separate.
To verify which Java version the terminal sees:
java -version
It was still showing Java 21. SDKMAN's sdk use command only affects the current terminal session, and I had opened a new terminal window.
Permanent fix:
sdk default java 17.0.10-tem
This sets Java 17 as the system default.
BUILD SUCCESSFUL in 12s
The JAR file appeared in build/libs/my-app-1.0.jar.
Why This Matters Beyond This Project
Version compatibility issues like this are everywhere in software development. Coming from IT support where most software has a "recommended system requirements" list and that's it, I didn't appreciate how fragile build ecosystems are.
The code you write today might not build tomorrow if:
- A dependency updates and breaks backward compatibility
- A build tool removes an internal API
- A language version introduces breaking changes
This is why production Dockerfiles specify exact versions (FROM openjdk:17.0.10-jdk), why package managers use lock files (package-lock.json, Gemfile.lock), and why some companies maintain internal mirrors of package repositories.
In DevOps, version management isn't a nice-to-have—it's the job.
Part 3: Deploying to the Server
With the JAR built, I needed to get it onto the server. I used scp (secure copy over SSH):
scp build/libs/my-app-1.0.jar root@<droplet-ip>:~/
This copied the file to the root user's home directory on the server.
Then I SSH'd into the server and tried to run it:
java -jar my-app-1.0.jar
bash: java: command not found
Right. The server doesn't have Java installed.
Installing Java on the Server
# Update package lists
sudo apt update
# Install OpenJDK 17
sudo apt install openjdk-17-jdk -y
# Verify
java -version
Output:
openjdk version "17.0.10"
Perfect.
java -jar my-app-1.0.jar
The application started:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
2026-02-10 14:23:15.441 INFO --- [main] Application started on port 7071
Success! The app was running.
I opened my browser: http://143.198.XXX.XXX:7071
Connection timed out.
The Firewall (Again)
I had forgotten to open port 7071 in the DigitalOcean firewall. I went back to the dashboard and added an inbound rule:
Allow TCP port 7071 from all IPv4 and IPv6
Refreshed the browser.
It worked. The application's web interface loaded.
Security Problem: Why "All IPs" Is Bad
In my excitement to see it working, I opened port 7071 to the entire internet. In production, this would be a mistake.
Better approach:
- Use a reverse proxy (nginx) on ports 80/443
- Keep the application on localhost only (not exposed)
- Use HTTPS with a real SSL certificate
- Implement rate limiting and authentication
But for a learning environment with a throwaway app, it was fine.
Part 4: The Principle of Least Privilege
At this point, I had a working deployment. But I was running everything as the root user, which is terrible security practice.
Why running apps as root is dangerous:
If your application gets compromised—a SQL injection, a remote code execution vulnerability, a misconfigured file upload—an attacker would have full root access to the entire server. They could:
- Install backdoors
- Steal SSH keys
- Pivot to other systems on your network
- Use your server for crypto mining
- Delete everything
The principle of least privilege says: Every process should have the minimum permissions required to do its job, and no more.
Creating a Dedicated Application User
# Create new user
adduser appuser
# Add to sudo group (so they can perform admin tasks when needed)
usermod -aG sudo appuser
I set a strong password and filled in the account details.
Testing the New User
# Switch to the new user
su - appuser
# Try to run something that requires root
systemctl restart nginx
It prompted for the password (because appuser is in the sudo group), but it worked. Good.
Now I tried to SSH as this user:
ssh appuser@<droplet-ip>
Permission denied (publickey).
The SSH Key Problem
Right—SSH keys are per-user, not system-wide. The public key I added during server creation was placed in /root/.ssh/authorized_keys. The appuser account has no SSH keys configured.
Solution: Create the SSH directory for the new user and add my public key.
# As root, create the .ssh directory
sudo mkdir -p /home/appuser/.ssh
# Create the authorized_keys file
sudo vim /home/appuser/.ssh/authorized_keys
I pasted my public key (same one from my laptop), saved it, and set the proper permissions:
# Directory: Only owner can read, write, execute
sudo chmod 700 /home/appuser/.ssh
# File: Only owner can read and write
sudo chmod 600 /home/appuser/.ssh/authorized_keys
# Make sure appuser owns these files
sudo chown -R appuser:appuser /home/appuser/.ssh
SSH is extremely strict about file permissions for security reasons. If .ssh/ or authorized_keys are too permissive (e.g., world-writable), SSH will refuse to use them because it assumes they've been tampered with.
The permissions must be:
700(drwx------) for.ssh/directory600(-rw-------) forauthorized_keysfile
I exited the server completely and tried again:
ssh appuser@<droplet-ip>
No password prompt. Success.
Now I could copy the JAR to the new user's directory and run it without root privileges:
# As appuser
cd ~
# (copy JAR here)
java -jar my-app-1.0.jar
The application ran, but now with restricted permissions. If it gets compromised, the attacker is limited to what appuser can do—which is significantly less than root.
What's Still Missing (Production Considerations)
In a real production environment, I wouldn't just run java -jar manually. I would:
Set up a systemd service:
[Unit]
Description=My Java Application
After=network.target
[Service]
Type=simple
User=appuser
WorkingDirectory=/home/appuser
ExecStart=/usr/bin/java -jar /home/appuser/my-app-1.0.jar
Restart=on-failure
[Install]
WantedBy=multi-user.target
This would:
- Start the app automatically on server boot
- Restart it if it crashes
- Log output to journald (viewable with
journalctl)
Add monitoring:
- Prometheus + Grafana for metrics
- Application-level logging (not just console output)
- Alerts for high CPU, memory, or error rates
Implement proper networking:
- Nginx reverse proxy on port 80/443
- SSL certificate from Let's Encrypt
- Application running on localhost only (not exposed)
Cost-benefit analysis:
For this learning project, none of that was necessary. But understanding the gap between "works" and "production-ready" is part of the learning process.
What I Learned (The Real Takeaways)
1. Build Tool Ecosystems Are Fragile
Coming from tier 1 IT support, I was used to software that "just works." You install it, you run it. Build tools don't work that way.
Gradle, Java, and Spring Boot all have to be compatible with each other. Upgrading one without checking the others breaks everything. This isn't a bug—it's the nature of software that's constantly evolving.
Production implication: Lock down versions in Docker, use dependency managers, maintain internal package mirrors.
2. IntelliJ ≠ Terminal
IDEs like IntelliJ have their own JDK configurations that are separate from your system PATH. When you run Gradle from the IDE, it uses IntelliJ's JDK. When you run it from the terminal, it uses your system's default Java.
This is a source of "works on my machine" problems. Your teammate's IDE might use Java 17, but their system Java might be 11, and builds will behave differently.
Solution: Use SDKMAN or similar tools to manage versions explicitly.
3. Firewalls Exist (And They Matter)
In local development, everything runs on localhost and firewalls don't matter. In the cloud, you have to explicitly allow traffic on every port you want to expose.
This is a good thing. It forces you to think about what should be accessible from the internet.
In production: Use multiple firewall layers (cloud firewall + OS firewall + application-level access control).
4. SSH Key Authentication Is Non-Negotiable
Password authentication for SSH is disabled by default on most cloud providers for a reason: it's too easy to brute-force.
SSH keys are:
- Impossible to brute-force (4096-bit RSA)
- Never transmitted over the network
- Easy to revoke (just remove from
authorized_keys)
Lesson: Never enable password auth for SSH in production. Ever.
5. Per-User Permissions Are More Work, But Necessary
Creating dedicated users for each application is annoying. You have to set up SSH keys, copy files, manage permissions. But it's the right way to do it.
If your app gets hacked and it's running as root, your entire server is compromised. If it's running as appuser, the damage is contained.
Real-world parallel: In my IT support job at LSU AgCenter, we never give users local admin rights on their workstations for exactly this reason. Same principle applies to servers.
Reflection: What I'd Do Differently
If I were doing this for a real company, not a learning project:
What I did (sufficient for learning):
- ✅ SSH key authentication
- ✅ Dedicated application user
- ✅ Cloud firewall rules
What I'd add (for production):
- ❌ systemd service for automatic restart
- ❌ Nginx reverse proxy with HTTPS
- ❌ Centralized logging (not just console output)
- ❌ Monitoring and alerting
- ❌ Automated backups
- ❌ Configuration management (Ansible/Terraform)
The difference between "working" and "production-ready" is substantial, and I'm okay with that. The goal here is to learn the fundamentals, not build a bulletproof system.
Time and Cost Investment
Time spent: ~4 hours (including troubleshooting)
Cost: $6/month for the DigitalOcean droplet
Breakdown:
- 30 min: Server provisioning and SSH setup
- 90 min: Build tool debugging (Gradle, Java versions)
- 45 min: Deployment and firewall configuration
- 45 min: User setup and SSH key management
Was it worth it? Absolutely. I learned more about build tools, Linux security, and version management in one afternoon than I did in an entire semester of coursework.
What's Next
This was just the foundation. Next, I'm setting up Nexus Repository Manager on another server to centrally manage build artifacts. Instead of copying JAR files manually with scp, I'll be publishing them to Nexus and pulling from there—simulating how a real CI/CD pipeline would work.
I'm expecting:
- More version conflicts
- More firewall issues
- Some new authentication problems I haven't encountered yet
But that's the point. Every time something breaks, I learn how these systems actually work under the hood. And every time I fix it myself, I get a little closer to being the kind of engineer who can walk into a production environment and actually solve problems instead of escalating them.
If you're on a similar journey—whether through Nana's roadmap or somewhere else—feel free to reach out. I'd love to hear about your "wait, why isn't this working?" moments. That's where the real learning happens.