Setting up an NGINX reverse proxy

The final config file setup is at the end if you just want the answers. This post is pretty choppily written and glosses over some different sections since I wrote it more as my notes as I was going through the whole process, but it still outlines the main points and talks through reasoning.

Reasons I wanted a reverse proxy:

  1. I can’t run a node server on 80 or 443 without sudo. That’s not a good call. Also not a huge deal right now though because I have an intermediary external-facing router/IP forwarding its 80 traffic to 8080 on the Pi.
  2. I can’t run multiple servers on the same IP. Since cnames can only map to an IP, the external-facing router/IP will only ever get requests on 80. Theoretically an SRV entry can mitigate this, but I couldn’t get it working for my domain. Hopefully the single-hop that’s forwarded from the router to my Pi won’t strip any data that would prevent me from still routing requests to the correct servers depending on domain (highly doubtful, but an unknown nonetheless).

Even though it’s for an apache proxy, this site was a useful visualization of what I was trying to do.

First, I wanted to set up reverse proxy just to handle incoming connections and direct them to the currently running server on the same port it’s already on. To test this I just had to change the port-forwarding rule of my router to forward to whatever port the reverse proxy will be listening on.

NGINX has pretty clear docs to follow for most of the configuration steps I outline here. I started here for getting it installed.

I went with NGINX Open Source because I definitely don’t need to be paying for something that isn’t being used by anyone other than myself and also that I have no idea how to use.
I used the mainline release and the prebuilt binary:

sudo apt install nginx

If you have a server listening on 80 (or have pihole install with its webserver listening on 80) the install will toss out errors. sudo netstat -lntp will show you what process you need to kill.

curl 127.0.0.1 works now, yay. Took some cache clearing and refreshes, but both cnames that point to the external router display the nginx http page as well after switching the router forwarding rule from 80->8080 to 80->80, since nginx was listening on 80.

Here are the docs for configuring NGINX to listen on the correct ports for your site specifically.

Next I created a server entry in /etc/nginx/conf.d/http.conf:

server {
    listen 127.0.0.1:80;
    server_name *brianteam.dev*
}

Here are the docs for reverse proxying using NGINX.

Following those, I added

location * {
    proxy_pass http://localhost:8080;
}

to the config file since my express node.js server was listening on 8080.

sudo nginx -s reload

loads these conf changes.

If you get an error about server names, don’t forget the http:// in the proxy_pass.

I was expecting to have to add an http conf file/block somewhere in here to handle https connections, but it seems like it handles it mostly out of the box with my express server redirecting 80 to 443. Sending a request to https://brianteam.dev correctly gets rerouted to https and then all of my following browses are https. Sending a request to brianteam.dev:443

Well I just found out it wasn’t actually working. I could get through to my default page, but I had left my port forwarding redirect up from 443->8443 on my router, so it wasn’t actually running through nginx for anything else. Once I removed this, everything stopped working for me and only would server the basic nginx page at my url root and nothing else. After some sleuthing I discovered the default config within sites-included which was overriding a lot of my server listening stuff. After removing that file (which is still available in sites-available for future reference) my requests to my domain were correctly getting rerouted to the proxy_pass I had defined in my conf.

That didn’t help me a ton though, because now my browser was trying to load its own localhost:8080, which was obviously wrong. When curling localhost on the Pi, I got the expected redirect message from express saying it was redirecting from http -> https. When I curled using the -L switch (follow redirects) I got a weird error:
curl: (35) error:140770FC:SSL routines:SSL23_GET_SERVER_HELLO:unknown protocol

This led me to stackoverflow and the realization that my express http->https redirect was somehow rerouting this new https request back through the nginx proxy (which was weird because shouldn’t it only be listening on 80?) which was then passing through to the same https proxy_pass URL. It’s redirects all the way down.

I changed the URL to be https with a port of 8443 (which my https express server was listening on). This loaded my index successfully through curl and my remote browser. There was no https from the browser’s point of view though, since the https-forced requests were just going between nginx and the web server on the same machine. Adding a listen 443 ssl; line to the same http server block didn’t change anything.

…an hour of googling and trying different things passes…

I’m kind of dumb. I thought I could test along the way but needed to fully finish setting up the ssl stuff in nginx. In the end my problem was just not having ssl_certificate and ssl_certificate_key set in the server block listening on 443. So you can listen for 80 and 443 in the same block and the response passed back will be whatever type the request is. OR you can do what I’m choosing to do and handle the http->https redirect in nginx so it’s https all the way through, versus nginx talking to the local server in http or https (since the node server has http->https redirecting). The former is the smarter thing to do since you get the security of TLS from browser to server and back.

Full text of http.conf:

# redirect all http traffic to https
server {
    listen 80;

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    ssl_certificate /path/to/ssl/cert;
    ssl_certificate_key /path/to/ssl/key;
    location / {
        proxy_pass https://localhost:8443;
    }
}

Next is to support the splitting of servers based on request URL.
First, I added a catch-all block to handle non-matching server_name traffic since our default_server will respond for any https url

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;

    ssl_certificate /etc/letsencrypt/live/brianteam.dev/cert.pem;
    ssl_certificate_key /etc/letsencrypt/live/brianteam.dev/privkey.pem;

   return 404;
}

Also, I removed the default_server from the other https block and add in the specific server name it should route. I tested this by navigating to the correct server_name, viewing the page, then trying my other domain that points to the same IP, which 404d as expected.

UPDATE:

This is way easier to let the LetsEncrypt certbot tool handle for you. If you run it for the NGINX-specific setup, it’ll set up all of the ssl-only URL matching and forwarding for you so you don’t ever have to touch the conf file. It’s nice to at least poke through it so you know what’s going on behind the scenes, but that way it’s set up all nice and pretty and supports auto-renewing your certificate with no extra work from you.

My end http.conf file has a single * routing rule to forward all http to https, and then the two certbot-generated routing blocks for each separate domain I’m running the server for (you can tell by the little # managed by Certbot comments appended to those lines).

# redirect all http traffic to https
server {
    listen 80;
    listen [::]:80;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    ssl_certificate /etc/letsencrypt/live/brianteam.dev/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/brianteam.dev/privkey.pem; # managed by Certbot

    server_name brianteam.dev *.brianteam.dev;

    location / {
        proxy_pass https://localhost:8443;
    }

    # Note: You should disable gzip for SSL traffic.
    # See: https://bugs.debian.org/773332
    #
    # Read up on ssl_ciphers to ensure a secure configuration.
    # See: https://bugs.debian.org/765782
    # include snippets/snakeoil.conf;

}

server {
    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/other.domain.app/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/other.domain.app/privkey.pem; # managed by Certbot
 
    server_name other.domain.app *.other.domain.app;

    location / {
        proxy_pass https://localhost:9443;
    }

}