Gunicorn on Unix Domain Socket

Published on 25 Feb 2023

I wrote two web applications for personal use. The backend for both apps was written in Python, a combination of Pandas and Flask. To expose the service, the backend was run on a specific port.

As for the UI, browser consumes the APIs through JS. This was when annoyance struck: the API endpoints were designed to be exposed only via localhost for security, therefore necessitated SSH tunneling in its early design. This was not so much of an issue since I exposed the ports through a WireGuard VPN interface. However, for certain situations where I could not use WireGuard, I was left with SSH tunneling. Then I realized that it would be a hassle needing to expose 2 or more ports at once.

I wrote a bash function to alleviate the pain, but that was only treating the symptom. I needed a more permanent solution.

# Function
function tunnel() { ssh -L ${1}:localhost:${1} {$2} -NC }

# Establishing a tunnel
tunnel 8081 vps

Gunicorn is a WSGI HTTP server. Most tutorials would show how to bind a Gunicorn server on a port. The line below assumes there is a Flask application object app inside the api.py file, hence api:app directive.

gunicorn --workers=2 --bind=127.0.0.1:8081 api:app

But recently, I learned that Gunicorn can listen on a Unix domain socket.

gunicorn --workers=2 --bind unix:app.sock -m 007 api:app

The -m 007 parameter sets the app.sock accessible only to the current $USER.

Now, the next part is accessing app.sock through a reverse proxy. Nginx can be used for reverse-proxying, but I chose Caddy because of its portability. A Caddyfile local to the project directory is as follows:

:8081 {
  root * webroot
  file_server

  handle_path /api/* {
    reverse_proxy * unix/app.sock
  }
}

Issuing caddy run in this application directory runs a Caddy server on port :8081, serving the frontend UI webroot, accessible at 0.0.0.0:8081. The API backend is accessible at 0.0.0.0:8081/api, thanks to the handle_path directive that reverse-proxies to app.sock socket.

Figuring this out took a while, but when it worked it felt like magic.