How to Set Up Nginx as a Reverse Proxy on Ubuntu: Clean URLs for Every Service

You’ve got services running on your home server — a WordPress site on one port, a dashboard on another, maybe a Jellyfin instance for movie night. But right now, accessing them means typing 192.168.1.6:8080 or 192.168.1.6:8096 like some kind of digital archaeologist. There’s a better way.

Enter Nginx as a reverse proxy. Instead of remembering port numbers, you get clean URLs like wordpress.home and jellyfin.home. Add HTTPS with Let’s Encrypt, and you’ve got something that feels like a “real” setup — because it is.

In this guide, I’ll walk you through setting up Nginx as a reverse proxy on Ubuntu, configuring virtual hosts for multiple services, and adding free SSL certificates. By the end, your homelab will feel less like a science project and more like infrastructure.

What You’ll Need

  • A running Ubuntu server (20.04, 22.04, or 24.04)
  • SSH access with sudo privileges
  • A domain name (even a free one from Freenom or a cheap .com works)
  • DNS records pointing to your server’s IP address
  • About 30 minutes

Wait, What’s a Reverse Proxy Anyway?

Think of a reverse proxy as a receptionist for your server. When someone walks in asking for “the WordPress department,” the receptionist knows exactly which office to send them to — the visitor doesn’t need to know the room number.

Technically, Nginx sits between the internet and your backend services. It receives incoming HTTP/HTTPS requests, looks at the domain name or path, and forwards the request to the right service running on the right port. The client never sees the port numbers — they just see clean URLs.

This gives you three big wins:

  • Clean URLs: blog.yourdomain.com instead of yourdomain.com:8080
  • SSL termination: Nginx handles HTTPS encryption so your backend services don’t have to
  • Single entry point: One server block manages traffic for multiple services

Step 1: Install Nginx

Update your package list and install Nginx from Ubuntu’s repositories:

sudo apt update
sudo apt install nginx -y

Once installed, Nginx should start automatically. Verify it’s running:

sudo systemctl status nginx

You should see active (running) in green. If you visit your server’s IP in a browser, you’ll see the default Nginx welcome page — that’s how you know it’s working.

Step 2: Set Up DNS Records

For each service you want to proxy, you’ll need a DNS A record pointing to your server’s public IP. If you’re only running this locally (behind your router), you can use your local DNS or edit /etc/hosts instead.

For a public setup, create records like this in your DNS provider’s dashboard:

blog.yourdomain.com    A    203.0.113.50
jellyfin.yourdomain.com    A    203.0.113.50
dashboard.yourdomain.com   A    203.0.113.50

All of these point to the same IP — Nginx will sort them out based on the domain name.

Local-only? Use /etc/hosts

If this is purely for local network access, skip public DNS and add entries to /etc/hosts on each device:

192.168.1.6    blog.home
192.168.1.6    jellyfin.home
192.168.1.6    dashboard.home

Step 3: Create Your First Server Block

Nginx uses “server blocks” (similar to Apache’s virtual hosts) to handle different domains. The default config lives at /etc/nginx/sites-available/default, but it’s better practice to create separate files for each service.

Let’s say you’re running WordPress in Docker on port 8080. Create a new server block:

sudo nano /etc/nginx/sites-available/blog

Paste this configuration:

server {
    listen 80;
    server_name blog.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;
    }
}

Let’s break down what each directive does:

  • listen 80: Accept HTTP traffic on port 80
  • server_name: Only respond to requests for this domain
  • proxy_pass: Forward requests to your backend service
  • proxy_set_header: Pass along the original host, client IP, and protocol info so the backend service knows what’s going on

Those proxy_set_header lines are important. Without them, your backend service might see all traffic as coming from 127.0.0.1 and generate URLs with http://localhost:8080 — which breaks everything.

Step 4: Enable the Site

Create a symlink to enable the site, then test the configuration:

sudo ln -s /etc/nginx/sites-available/blog /etc/nginx/sites-enabled/
sudo nginx -t

You should see:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

If you get an error, double-check your config file for typos — missing semicolons are the #1 culprit.

Reload Nginx to apply the changes:

sudo systemctl reload nginx

Now visit http://blog.yourdomain.com and you should see your WordPress site. No port numbers in sight.

Step 5: Add More Services

Rinse and repeat for each service. Here’s an example for Jellyfin running on port 8096:

sudo nano /etc/nginx/sites-available/jellyfin
server {
    listen 80;
    server_name jellyfin.yourdomain.com;

    location / {
        proxy_pass http://127.0.0.1:8096;
        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;

        # Jellyfin-specific: support WebSocket connections
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Enable and reload:

sudo ln -s /etc/nginx/sites-available/jellyfin /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 6: Add HTTPS with Let’s Encrypt

HTTP is fine for local testing, but if your services are exposed to the internet, you want HTTPS. Let’s Encrypt provides free SSL certificates, and Certbot makes the process painless.

Install Certbot:

sudo apt install certbot python3-certbot-nginx -y

Obtain and install certificates for all your domains:

sudo certbot --nginx -d blog.yourdomain.com -d jellyfin.yourdomain.com -d dashboard.yourdomain.com

Certbot will ask for your email (for renewal notices) and whether to redirect HTTP to HTTPS. Say yes to the redirect — it’s the right call.

Behind the scenes, Certbot modifies your server blocks to:

  • Listen on port 443 with SSL
  • Redirect all HTTP traffic to HTTPS
  • Auto-renew certificates before they expire

Verify auto-renewal is set up:

sudo certbot renew --dry-run

If that succeeds, you’re golden. Certbot sets up a systemd timer that handles renewal automatically.

Step 7: Harden Your Nginx Config (Optional but Recommended)

While you’re at it, let’s add some security headers and hide Nginx’s version number. Create a snippet file:

sudo nano /etc/nginx/snippets/security.conf
# Hide Nginx version
server_tokens off;

# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Limit request size (adjust as needed)
client_max_body_size 100M;

Include this snippet in each of your server blocks:

server {
    listen 443 ssl;
    server_name blog.yourdomain.com;

    include /etc/nginx/snippets/security.conf;

    location / {
        proxy_pass http://127.0.0.1:8080;
        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;
    }
}

Common Pitfalls

502 Bad Gateway

This means Nginx can’t reach your backend service. Check that the service is actually running on the port you specified:

sudo ss -tlnp | grep 8080

If nothing’s listening, your backend service might have crashed or isn’t bound to 0.0.0.0 (some services default to 127.0.0.1 only, which is fine for a reverse proxy on the same machine).

Redirect loops

If your backend service (like WordPress) tries to redirect to its own URL and Nginx is also redirecting, you can end up in a loop. Make sure your backend service knows its own URL:

# In WordPress wp-config.php
define('WP_HOME', 'https://blog.yourdomain.com');
define('WP_SITEURL', 'https://blog.yourdomain.com');

Mixed content warnings

If you see HTTPS in the browser but some resources load over HTTP, your backend service is generating http:// URLs. The X-Forwarded-Proto header we set earlier usually fixes this, but some apps need additional configuration to respect it.

Forgetting to reload Nginx

Editing config files isn’t enough — you need to reload Nginx for changes to take effect. systemctl reload is graceful (it finishes serving existing connections before reloading). systemctl restart is more abrupt but sometimes necessary.

Putting It All Together

Here’s what your final setup looks like:

/etc/nginx/
├── nginx.conf
├── sites-available/
│   ├── blog          → WordPress on :8080
│   ├── jellyfin      → Jellyfin on :8096
│   └── dashboard     → Dashboard on :3000
├── sites-enabled/
│   ├── blog          → ../sites-available/blog
│   ├── jellyfin      → ../sites-available/jellyfin
│   └── dashboard     → ../sites-available/dashboard
└── snippets/
    └── security.conf

Clean, organized, and easy to maintain. Adding a new service is just creating a new file in sites-available/, symlinking it, and reloading.

Wrapping Up

Setting up Nginx as a reverse proxy is one of those tasks that pays dividends every single day. No more memorizing port numbers. No more explaining to family members why they need to type :8096 after the URL. Just clean, secure, professional-looking URLs for everything running on your server.

And the best part? This setup scales. Whether you’re running three services or thirty, the pattern is the same: one server block per service, proxy headers, and Let’s Encrypt for SSL. Once you’ve done it a couple of times, you can spin up a new reverse proxy config in under five minutes.

Your homelab deserves nice things. Go give it some.

Leave a Reply

Your email address will not be published. Required fields are marked *