How to Deploy a Spring MVC Application Behind an Nginx Reverse Proxy
Apr 2019
Nginx is a popular webserver, excellent at serving up static content, and commonly used as a load balancer or reverse proxy. This post will set up a basic Spring Boot MVC web application, and use Nginx as a reverse proxy. The source code can be found on GitHub.
What is a Reverse Proxy?
A reverse proxy is the middle-man between the consumer of your service/application and the application itself. Take, for example, somebody typing your web domain (https://your-awesome-domain.com) into her browser. After the browser finds the IP address of where your web domain is located, her browser sends an HTTP request to the browser at the specified endpoint. If you don't have a reverse proxy set up, then your application will have to be listening for active connections at your-awesome-domain.com:443, and be capable of serving up a CA-signed certificate along with the response. If your site doesn't have https configured, then it must be listening at port 80 (and obviously would not need any certs).
This is fine for some applications. However, what if you want to put another web app on the same VM (because you're cheap, like me)? With this setup, you're going to need something in between your web applications to decide which one to forward the request to. That "something" is a reverse proxy. The person deciding to come to your site still enters https://your-awesome-domain.com into the browser. Before, her signal would look like: [her] -> [your web app]. Now, her request signal looks like [her] -> [reverse proxy] -> [your web app].
Common reasons for reverse proxies include making zero downtime deployments much easier to do, handling certificate management for https-enabled sites, cramming as many web apps on your server as the thing can handle, or selectively caching content--which I will cover in the next post.
Create an Ansible Role
I will use Ansible to provision the server for this example. You will need ansible, molecule, vagrant, and virtual box on your machine to follow along.
Navigate to the directory that you want this project to go into and type:
$ molecule init role -r reverse-proxy-nginx -d vagrant
I'm using vagrant as the VM provider here, and the ansible role I'm creating is called "reverse-proxy-nginx." Modify the molecule/default/molecule.yml file's providers section to the following:
platforms:
- name: reverse-proxy
box: ubuntu/xenial64
memory: 1024
provider_raw_config_args:
- "customize ['modifyvm', :id, '--uartmode1', 'disconnected']"
interfaces:
- auto_config: true
network_name: private_network
ip: 192.168.56.202
type: static
The first thing we will need is a Spring MVC application to work with. For simplicity, we're going to keep the app source code within this ansible role. In a real application, you should set up a CI/CD pipeline that the application would go through, but I'm going to keep the focus on getting a working example. Create a app/ directory, then go to the spring initializer and select the "Web" dependency option, then Maven as the build engine. Place the generated code inside the app/ directory.
We can then add a SimpleController.java class to the app/src/main/java/com/nickolasfisher/simplemvc/ directory that is, well, simple:
package com.nickolasfisher.simplemvc;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class SimpleController {
@GetMapping("/**")
public ResponseEntity<String> simpleResponder() {
return new ResponseEntity<>("<h1>Welcome to my site!</h1>", HttpStatus.ACCEPTED);
}
}
On the server, we are going to need Java and Nginx. In the source code for this post, I've included two separate roles called openjdk and nginx. The ansible role that gets Java looks like:
---
- name: Get Java tarball
get_url:
url: "{{ jdk_url }}"
dest: /etc/{{ jdk_tarball }}
become: yes
- name: make java directory
file:
path: "/usr/lib/openjdk-{{ jdk_version }}"
state: directory
become: yes
- name: unpack tarball
unarchive:
dest: "/usr/lib/openjdk-{{ jdk_version }}/"
src: /etc/{{ jdk_tarball }}
remote_src: yes
become: yes
- name: update alternatives for java
alternatives:
name: java
path: "/usr/lib/openjdk-{{ jdk_version }}/jdk-{{ jdk_version }}/bin/java"
link: /usr/bin/java
priority: 2000
become: yes
- name: set java environment variable
blockinfile:
insertafter: EOF
path: /etc/environment
block: export JAVA_HOME=/usr/lib/openjdk-{{ jdk_version }}/jdk-{{ jdk_version }}
become: yes
- name: re-source env variables
shell: . /etc/environment
become: yes
Where the variables are:
---
# vars file for openjdk
jdk_version: 11.0.2
jdk_tarball: openjdk-{{ jdk_version }}_linux-x64_bin.tar.gz
jdk_url: "https://download.java.net/java/GA/jdk11/9/GPL/{{ jdk_tarball }}"
The Nginx role playbook looks like:
---
# tasks file for nginx
- name: install nginx
apt:
name: nginx
update_cache: yes
become: true
- name: install nginx conf file
template:
dest: /etc/nginx/sites-available/{{ site_alias }}.conf
src: server.conf.j2
become: yes
- name: link nginx conf file
file:
src: /etc/nginx/sites-available/{{ site_alias }}.conf
dest: /etc/nginx/sites-enabled/{{ site_alias }}.conf
state: link
become: yes
- name: remove nginx defaults
file:
path: "{{ item }}"
state: absent
with_items:
- /etc/nginx/sites-available/default
- /etc/nginx/sites-enabled/default
become: yes
when: remove_nginx_defaults
- name: reload nginx
command: nginx -s reload
become: yes
With variables like:
---
# vars file for nginx
app_port: 8080
site_alias: my-site
remove_nginx_defaults: true
We also use a Jinja2 template for the configuration file, which is very simple (inside templates/server.conf.j2):
server {
location / {
proxy_pass http://localhost:{{ app_port }};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $http_host;
proxy_cache_bypass $http_upgrade;
}
}
This nginx conf file is where you can insert all of the middle-man logic that you want. in this case, we set a few headers and the http version. But we can easily add TLS management with certbot if we so choose.
Back inside our reverse-proxy-nginx role, we need to add these roles as dependencies in the meta/main.yml file:
---
dependencies:
- role: openjdk
- role: nginx
We can now move on to the logic of deploying it. Again, in a production setup, we would want an automated CI/CD pipeline to handle deployments, preferably only deploying a release branch. However, for this simple example, we will build it using ansible and send up the artifact directly. The only protection that this offers is building and running the unit tests, but configuration management basically doesn't exist.
Note that you will need maven installed on your system for this to run properly.
We need to get the jar artifact on the server, then create a systemd service file that will run it for us. In the reverse-proxy-nginx/tasks/main.yml:
---
# tasks file for reverse-proxy-nginx
- name: ensure files dir empty
file:
path: ../../files/{{ jar_name }}
state: absent
delegate_to: localhost
- name: build solution and move jar to files
shell: pwd && cd ../../app && mvn clean install && cp target/*.jar ../files/{{ jar_name }}
delegate_to: localhost
- name: ensure apps dir exists
file:
path: "{{ jar_dir }}"
state: directory
mode: 0755
become: yes
- name: send jar
copy:
src: "{{ jar_name }}"
dest: "{{ path_to_jar }}"
mode: 0755
force: yes
become: yes
notify: restart app
- name: setup service file
template:
src: app.service.j2
dest: /etc/systemd/system/app.service
become: yes
notify: restart app
We will need a Jinja2 template for systemd in templates/app.service.j2:
[Unit]
Description=slow server to demonstrate caching
[Service]
WorkingDirectory={{ jar_dir }}
ExecStart=/usr/bin/java -jar {{ path_to_jar }}
Restart=always
[Install]
WantedBy=multi-user.target
And variables in vars/main.yml:
---
# vars file for reverse-proxy-nginx
jar_dir: /opt/apps/app
jar_name: app.jar
path_to_jar: "{{ jar_dir }}/{{ jar_name }}"
Finally, we need to set up a handler in the handlers/main.yml to restart the app when we send it up:
---
# handlers file for reverse-proxy-nginx
- name: restart app
systemd:
name: app.service
daemon_reload: yes
state: restarted
become: yes
You should now be able to run:
$ molecule create && molecule converge
And see the VM come up at http://192.168.56.202.
Definitely go check out the source code to see this in action.
Nick Fisher is a software engineer in the Pacific Northwest. He focuses on building highly scalable and maintainable backend systems.