Reverse Proxies

Aug 25, 2020

A reverse proxy is a server application that sits in front of one or more web servers and forwards client requests to those web servers. Reverse proxies can be used for (but are not limited to):

  • offloading web server TLS encryption
  • reducing server loads by caching static and dynamic content
  • optimizing web content with compression
  • adding basic HTTP access authentication to web servers
  • allowing traffic to multiple web servers though a single IP address

For this post I will set up a reverse proxy on an Ubuntu 20.04 server using a Nginx Docker container with TLS encryption provided by a self-signed certificate. Nginx is a web server that serves a large chunk of websites on the internet and can also be used as a reverse proxy. I will use Jellyfin as an example of an application to self host.See previous posts on Docker and Docker Compose for an installation and configuration refresher.

Note: self-signed certificates are not sufficient for services open to the public internet. I use this reverse proxy setup for LAN use only.

Setup

I have created a github repo for this project as there are a fair number of configuration files and directories invovled. First download the repo and generate a self-signed certificate using OpenSSL. For an in depth explanation of OpenSSL, see previous post here:

$ cd ~/ && git clone https://github.com/petebuffon/reverse_proxy.git
$ cd reverse_proxy/nginx && mkdir keys
$ openssl req \
  -x509 -nodes -days 365 \
  -subj '/C=US/ST=State/L=City/CN=www.example.com' \
  -newkey rsa:2048 -keyout ./keys/mycert.pem -out ./keys/mycert.pem

The default Nginx container image will automatically run as root and this is a bad idea. If you need further convincing, RedHat has a great write up on container security.

The default.conf and nginx.conf config files require some alterations to allow nginx be run as a non root user. The default listening port needs to be changed from 80 to 8080 in default.conf as unprivileged users can’t use ports under 1000. The directory paths in nginx.conf need to be changed from /var/run to /tmp/.

Compression

Compression can easily be enabled in nginx.conf to reduce the size of transmitted data. See the official nginx documentation on compression for more info:

...
gzip on;
gzip_disable msie6;
gzip_proxied no-cache no-store private expired auth;
gzip_types text/plain text/css application/x-javascript application/javascript text/xml application/xmapplication/xml+rss text/javascript image/x-icon image/bmp image/svg+xml;
gzip_min_length 1024;
gzip_vary on;
gunzip on;
...

Dockerfile

In the custom dockerfile we are simply copying the custom config files into a new container image, adding a new user nginx with uid 2000, and assigning the proper permissions to the new nginx user:

FROM nginx

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./conf.d /etc/nginx/conf.d
COPY ./keys /etc/nginx/keys

RUN userdel nginx \
    && useradd -u 2000 nginx \
    && chown -R nginx:nginx /etc/nginx /var/cache/nginx

USER nginx:nginx

Configs

If we take a look at the conf.d directory, we have four config files, defalt.conf, ssl.conf, proxy.conf, and jellyfin.conf. Separating out config files by role makes it easier down the line to make changes. Additionally config options in ssl.conf and proxy.conf will not need to be rewritten for every individual web service.

As the config files are copied into the container image at build time, changes made to config files will not be seen until the container image is rebuilt and deployed.

SSL

The lines from ssl.conf were copied almost verbatum from Mozilla’s SSL Configuration Generator (the modern option was chosen):

ssl_certificate /etc/nginx/keys/mycert.pem;
ssl_certificate_key /etc/nginx/keys/mycert.pem;

ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;

ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers off;

add_header Strict-Transport-Security "max-age=63072000" always;

Note: OCSP Stapling is not needed as a self-signed certificate is being used.

Proxy

The proxy.conf file sets some good options for proxying connections. The proxy_set_header directive alters headers seen by the client. Setting Host to the host variable ensures the correct domain name is set for the server. Setting X-Real-IP to the variable remote_addr and setting X-Forwarded-For to the proxy_add_x_forwarded_for variable allows the upstream server to see the original IP address of a connecting client:

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Note: If any proxy settings are changed in a config file, the entire set of proxy configs will need to be redefined as seen in jellyfin.conf.

Jellyfin

Jellyfin.conf contains two locations, a root location and a websocket location with different proxy headers. Port 8080 traffic is redirected to the TLS encrypted 8443 port, which is upgraded to http version 2.0.

In both locations the variable upstream is set to Jellyfin’s address at port 8096. Setting the resolver to Docker’s DNS IP, 127.0.0.11, ensures nginx is able to find the jellyfin server by name. The proxy is then set with proxy_pass to the upstream variable which was just set. If one proxied server went down, the reverse proxy would go down as well without this workaround. See Jellyfin’s documentation on reverse proxies for more details.

server {
  listen 8080;
  server_name jellyfin.example.com;
  return 301 https://$host$request_uri;
}

server {
  listen 8443 ssl http2;
  server_name jellyfin.example.com;
  resolver 127.0.0.11 valid=30s;

  location / {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Protocol $scheme;
    proxy_set_header X-Forwarded-Host $http_host;
    set $upstream http://jellyfin:8096;
    proxy_pass $upstream;
  }

  location /socket {
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Protocol $scheme;
    proxy_set_header X-Forwarded-Host $http_host;
    set $upstream http://jellyfin:8096/socket;
    proxy_pass $upstream;
  }
}

Now we’ll finally put it all together with a Docker Compose file:

version: '2'
services:
  reverse_proxy:
    build:
      context: ./nginx
    container_name: reverse_proxy
    environment:
      - TZ=America/Los_Angeles
    ports:
      - 80:8080
      - 443:8443
    networks:
      - default
      - public
    restart: unless-stopped
    
  jellyfin:
    image: linuxserver/jellyfin
    container_name: jellyfin
    environment:
      - PUID=4000
      - GUID=4000
      - TZ=America/Los_Angeles
    ports:
      - 1900:1900
    volumes:
      - jellyfin_config:/config
      /pathtomedia:/data
    devices:
      - /dev/dri:/dev/dri
    restart: unless-stopped

volumes:
  jellyfin_config:

networks:
  default:
  public:

Let’s get everything up:

docker-compose pull && docker-compose up -d

We’ll finally have to add a line to a client’s /etc/hosts file in order to reach the reverse proxy:

...
jellyfin.example.com    <server_IP>
...

Editing /etc/hosts on every client computer is your only option unless you’re hosting your own DNS server. As I using Pfsense for my router I just had to add a host override:

Services / DNS Resolver / General Settings

Go to the Host Overrides section

pfSense host override

Enter example.com in Domain and server_IP in IP Address, in Additional Names for this host enter jellyfin in Host and example.com in Domain. Click Save and Apply Changes. You should now be able to immediately navgivate to jellyfin.example.com.

useradd -u 4000 media