DEV Community

linou518
linou518

Posted on

Setting Up an App Hub with Nginx Reverse Proxy on Our Internal Dashboard

Setting Up an App Hub with Nginx Reverse Proxy on Our Internal Dashboard

We added an "App Hub" panel to our in-house AI agent management dashboard — a one-click access list to all our internal services. Here's what I learned about Nginx config and reverse proxying along the way.

Background

Our team runs a Flask-based SPA as an internal dashboard. As features grew, we needed a quick way to jump between services, so we created the App Hub panel.

Changes made this time:

  • Removed 3 retired services
  • Added 3 new services (TechsFree Shop, TechsFree ERP, Accounting AI)
  • Updated URLs for services that moved

Problem: Host Header with IP-Based Access

When referencing services on another internal server by IP address in App Hub links, Nginx wasn't routing to the right vhost.

The Nginx config assumes subdomain-based routing (techsfree.com, blog.techsfree.com, etc.). When accessing by raw IP, the Host header becomes the IP itself, which doesn't match any server_name.

Solution: Add Locations to the Default Server

# /www/server/panel/vhost/nginx/0.default.conf

server {
    listen 80 default_server;
    # ...

    location /shop/ {
        root /www/apps/techsfree-shop;
        try_files $uri $uri/ /shop/index.html;
    }

    location /erp/ {
        proxy_pass http://127.0.0.1:3101/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
Enter fullscreen mode Exit fullscreen mode

The key: add path-based locations to the default_server vhost. When accessing services directly by internal IP without a domain, this is the cleanest approach.

Static Files vs Reverse Proxy

Two services in the App Hub use different architectures:

Service Architecture Nginx Config
TechsFree Shop Vite-built static files root directive
TechsFree ERP Backend API + Frontend proxy_pass

Static files (Shop):

Build artifacts are placed in /www/apps/techsfree-shop/ and served directly by Nginx. Since it's an SPA, try_files $uri $uri/ /shop/index.html handles client-side routing.

Reverse proxy (ERP):

Backend runs on Node.js (ts-node) listening on port 3101. Nginx forwards requests to it. The critical detail is the trailing slash in proxy_pass:

# Wrong: /erp/ prefix gets passed through to the backend
location /erp/ {
    proxy_pass http://127.0.0.1:3101;  # no trailing slash
}

# Right: /erp/ prefix is stripped before forwarding
location /erp/ {
    proxy_pass http://127.0.0.1:3101/;  # with trailing slash
}
Enter fullscreen mode Exit fullscreen mode

Whether or not you include a trailing slash in the proxy_pass URL changes how the path is handled. Easy to miss, annoying to debug.

Managing systemd and PM2 Side by Side

Another lesson: mixing process managers causes confusion.

The dashboard runs on Ubuntu with systemd. I briefly tried moving it to a server running aaPanel (PM2), but hit permission issues and rolled back.

# Check on systemd server
systemctl --user status task-dashboard.service

# Check on PM2 server (if migrated)
pm2 status
Enter fullscreen mode Exit fullscreen mode

When the same service was running in two environments simultaneously, changes weren't being reflected — because systemd was spawning new processes without killing the old ones.

# Find stale processes
ps aux | grep server.py

# Kill explicitly, then restart
kill <PID>
systemctl --user restart task-dashboard.service
Enter fullscreen mode Exit fullscreen mode

Always check for duplicate processes after a deploy.

Bug Fix: Inconsistent JSON Field Names

While implementing a "bulk delete completed tasks" feature, I hit an easy-to-miss bug.

The task JSON uses completed, but the API's filter logic was checking done:

# Buggy
active_tasks = [t for t in tasks if not t.get('done')]

# Correct
active_tasks = [t for t in tasks if not t.get('completed')]
Enter fullscreen mode Exit fullscreen mode

Once you decide on a field name, keep it consistent across frontend, backend, and docs. Any deviation will cause bugs like this.

Summary

  1. Add locations to the default server to handle IP-based access
  2. Choose static files vs reverse proxy by use case (SPAs need try_files)
  3. Watch the trailing slash in proxy_pass — it changes path handling
  4. Check for duplicate processes after deploys
  5. Keep JSON field names consistent across the whole stack

Even small internal tools teach real lessons when run in a production-like setup.


Tags: nginx, reverse-proxy, spa, flask, systemd, deployment, webdev, infra

Top comments (0)