BLACKBOXHelp Center

Node.js, Python and other services: nginx as a reverse proxy

How to publish an application that listens on an internal port (3000, 8000) on 80/443 with HTTPS, and how to start it automatically with systemd.

generalUpdated 2026-10-09~12 min read
On this page

This guide shows you how to publish an application that listens on an internal port, for example Node.js on 3000 or Python (gunicorn, uvicorn) on 8000, on ports 80 and 443 with HTTPS. The application runs as a systemd service, and nginx receives requests from the internet and passes them to the application. You work in the server's terminal, connected over SSH as root (Connecting to your server over SSH (Windows, macOS, Linux)). The server's IP address is on the server card on the “My Servers” page of the app.blackbox.md panel (app.blackbox.md/my-servers). The panel has no settings for applications, web servers or ports.

Available to: anyone with root access over SSH to a BLACKBOX server running Ubuntu 24.04, 22.04 or 20.04, Debian 12 or 11, AlmaLinux 9 or CentOS Stream 9.

The blackbox.md website is available in Romanian and Russian; this article quotes the Romanian labels with their English meaning.

In the examples, the application is called myapp, lives in /srv/myapp and listens on port 3000. Replace these values and DOMAIN (your domain, without www) with your own.

Why put nginx in front of the application

Traffic coming into the server passes through network filtering managed by BLACKBOX. On a new server, it lets through only SSH (22), HTTP (80), HTTPS (443) and ping. An application that listens on port 3000, 8000 or 8080 can't be reached from the internet, even if the port is allowed in ufw or firewalld. Instead of asking for more ports to be opened, publish the application through nginx:

visitor ── HTTPS (443) ──> nginx ── HTTP ──> 127.0.0.1:3000 (application)
  • The application listens only on 127.0.0.1, so it can't be reached directly from the internet.
  • nginx handles HTTPS: certbot obtains and renews the Let's Encrypt certificate automatically.
  • You can run several applications on the same server, each on its own domain or subdomain, all on 80 and 443.

Before you start

Requirement Details
The domain points to the server the A record for DOMAIN (and for www.DOMAIN, if you use it) has the value SERVER_IP: How to point your domain to the server
The application starts the dependencies are installed, and the application starts manually, from the terminal, without errors
Ports 80 and 443 are free no other web server is listening on them

If you have a “VPS hosting” plan, don't install nginx before you ask support: a control panel installed on the server uses its own web server on ports 80 and 443. Support will tell you how to publish the application: “VPS hosting” plans with a control panel.

Install nginx if it isn't installed already. Ubuntu and Debian:

apt update
apt install -y nginx

AlmaLinux and CentOS Stream:

dnf install -y nginx
systemctl enable --now nginx

More about installing nginx: Web server with HTTPS: nginx or Apache and Let's Encrypt.

Step 1. The application listens only on 127.0.0.1

Configure the application to listen on 127.0.0.1 (connections from the server itself only), not on 0.0.0.0 (all addresses). Write 127.0.0.1, not localhost: on some systems localhost means the IPv6 address ::1, and then nginx can't find the application.

Application How to listen on 127.0.0.1
Node.js (http, Express) in the code: app.listen(3000, "127.0.0.1") (Express) or server.listen(3000, "127.0.0.1") (http)
Python with gunicorn (Django, Flask) the --bind 127.0.0.1:8000 option
Python with uvicorn (FastAPI) the --host 127.0.0.1 --port 8000 options
Docker container -p 127.0.0.1:3000:3000 with docker run, or "127.0.0.1:3000:3000" under ports: in Docker Compose

Check after the application starts. The output must show 127.0.0.1:3000; 0.0.0.0:3000 or *:3000 means the application is listening on all addresses.

ss -tlnp | grep ':3000'

Warning Docker bypasses the system firewall: a port published with -p 3000:3000, without 127.0.0.1, can't be closed with ufw or firewalld. Publish containers only on 127.0.0.1: Installing Docker and Docker Compose.

Step 2. Starting automatically with systemd

systemd starts the application when the server starts and restarts it if it stops with an error. If the application runs in a Docker container with restart: unless-stopped, go straight to step 3.

  1. Create a separate user that can't log in. Don't run the application as root: if the application is compromised, the attacker would control the whole server.

    useradd --system --create-home --home-dir /srv/myapp --shell /usr/sbin/nologin myapp
  2. Copy the application files to /srv/myapp (Transferring files to the server (scp, rsync, SFTP)) and install the dependencies. For Python, create a virtual environment. On Ubuntu and Debian, install the python3-venv package first; on AlmaLinux and CentOS Stream the module is already included.

    apt install -y python3-venv
    python3 -m venv /srv/myapp/venv
    /srv/myapp/venv/bin/pip install -r /srv/myapp/requirements.txt gunicorn

    For FastAPI, install uvicorn instead of gunicorn.

  3. Make the myapp user the owner of the files:

    chown -R myapp:myapp /srv/myapp
  4. Put the environment variables and secrets (passwords, API keys) in a separate file. On AlmaLinux and CentOS Stream, if the nano editor is missing, install it first.

    dnf install -y nano
    nano /etc/myapp.env

    Write one variable per line, in the form NAME=value, without export. The variable names depend on your application; for example:

    NODE_ENV=production
    DATABASE_URL=postgresql://myapp:PASSWORD@127.0.0.1:5432/myapp
    SECRET_KEY=SECRET_VALUE

    Make the file accessible only to root. systemd reads it before it starts the application and passes the variables to the application.

    chmod 600 /etc/myapp.env
  5. Create the service file:

    nano /etc/systemd/system/myapp.service
    [Unit]
    Description=myapp
    After=network.target
    
    [Service]
    User=myapp
    Group=myapp
    WorkingDirectory=/srv/myapp
    EnvironmentFile=/etc/myapp.env
    ExecStart=/usr/bin/node /srv/myapp/server.js
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target

    In the ExecStart line, write the full path of the program. For Node.js, find it with command -v node. Examples for other applications (replace project.wsgi, app:app and main:app with your application's module):

    Application ExecStart=
    Node.js /usr/bin/node /srv/myapp/server.js
    Django with gunicorn /srv/myapp/venv/bin/python -m gunicorn --workers 3 --bind 127.0.0.1:8000 project.wsgi:application
    Flask with gunicorn /srv/myapp/venv/bin/python -m gunicorn --workers 3 --bind 127.0.0.1:8000 app:app
    FastAPI with uvicorn /srv/myapp/venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000
  6. Load the new service, enable it (so it also starts after the server reboots) and check it. systemctl status must show active (running).

    systemctl daemon-reload
    systemctl enable --now myapp
    systemctl status myapp
  7. Follow the application's log in real time; exit with Ctrl+C.

    journalctl -u myapp -f

After making changes:

# after updating the application code
systemctl restart myapp
# after editing the myapp.service file
systemctl daemon-reload
systemctl restart myapp

Security Don't write passwords or keys directly in the service file (Environment=): any user on the server can read the service file and the output of the systemctl show command. Keep secrets in the file protected with chmod 600.

Step 3. Configuring nginx

  1. Create the WebSockets file once per server, however many applications you have:

    nano /etc/nginx/conf.d/connection-upgrade.conf
    map $http_upgrade $connection_upgrade {
        default upgrade;
        ''      close;
    }
  2. Create the site file: on Ubuntu and Debian /etc/nginx/sites-available/DOMAIN, on AlmaLinux and CentOS Stream /etc/nginx/conf.d/DOMAIN.conf. The content is the same:

    server {
        listen 80;
        server_name DOMAIN www.DOMAIN;
    
        client_max_body_size 20m;
    
        location / {
            proxy_pass http://127.0.0.1:3000;
            proxy_http_version 1.1;
    
            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 Upgrade $http_upgrade;
            proxy_set_header Connection $connection_upgrade;
        }
    }
  3. On Ubuntu and Debian only, enable the site:

    ln -s /etc/nginx/sites-available/DOMAIN /etc/nginx/sites-enabled/
  4. Check the configuration and reload nginx. nginx -t must print “syntax is ok” and “test is successful”; otherwise, fix the file before you reload.

    nginx -t
    systemctl reload nginx
  5. Open http://DOMAIN in your browser: your application should respond. On AlmaLinux and CentOS Stream, do step 4 first.

Directive What it does
proxy_pass the application's address and port; they must match the ones from step 1 (for gunicorn or uvicorn in the examples, http://127.0.0.1:8000)
Host the application receives the domain the visitor requested, not 127.0.0.1:3000
X-Real-IP, X-Forwarded-For the visitor's real address; without them, the application sees every request as coming from 127.0.0.1
X-Forwarded-Proto http or https, depending on how the request reached nginx
proxy_http_version 1.1, Upgrade, Connection needed for WebSockets (chat, real-time notifications)
client_max_body_size the maximum size of a request, for example an uploaded file; by default, nginx accepts 1 MB

Step 4. SELinux and the system firewall

AlmaLinux and CentOS Stream: SELinux

By default, SELinux doesn't allow nginx to connect to applications on ports such as 3000 or 8000. Without the setting below, the site responds with 502 Bad Gateway, and “(13: Permission denied) while connecting to upstream” appears in /var/log/nginx/error.log. Allow the connection; -P keeps the setting after a reboot.

setsebool -P httpd_can_network_connect 1
getsebool httpd_can_network_connect

The second command must print httpd_can_network_connect --> on.

The system firewall

On a new server, ufw and firewalld are inactive or not installed. If you have turned on the firewall, allow HTTP and HTTPS. You don't need to allow the application's port (3000): nginx connects to the application locally.

Ubuntu and Debian (ufw):

ufw allow 80,443/tcp

AlmaLinux and CentOS Stream (firewalld):

firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reload

Warning Customers have no web or VNC console: you manage the server only over SSH. Don't turn on the firewall just for this guide. If you do turn it on, allow SSH (port 22) first and keep a second SSH session open: Firewall: network filtering and ufw/firewalld on the server.

Step 5. HTTPS with Let's Encrypt

  1. Install certbot with the nginx plugin, as described in Web server with HTTPS: nginx or Apache and Let's Encrypt.

  2. Get the certificate:

    certbot --nginx -d DOMAIN -d www.DOMAIN
  3. certbot adds to the site file the configuration for port 443, with the certificate, and the redirect from HTTP to HTTPS. The proxy_* lines stay unchanged, and the certificate renews automatically.

  4. Open https://DOMAIN and check for the padlock in your browser.

The application must know it runs over HTTPS

Between nginx and the application, traffic stays HTTP. If the application ignores the X-Forwarded-Proto header, it thinks it runs over HTTP: it generates http:// links and redirects, cookies marked “secure” don't work, and sometimes you get a redirect loop. Set the application to trust the headers that nginx sends from 127.0.0.1:

Application Setting
Express (Node.js) app.set("trust proxy", "loopback")
Django in settings.py: SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"); also add the domain to ALLOWED_HOSTS
Flask app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1), with from werkzeug.middleware.proxy_fix import ProxyFix
FastAPI with uvicorn nothing to change: by default, uvicorn reads the headers that come from 127.0.0.1 (the --proxy-headers and --forwarded-allow-ips options)

In Express, Flask and uvicorn, the same setting lets the application see the visitor's real address. In Django, the real address is in the X-Real-IP header, available in code as request.META["HTTP_X_REAL_IP"].

Several applications on the same server

Each application has its own systemd service, its own internal port and a separate server block in nginx, with its own domain or subdomain:

Address systemd service proxy_pass
DOMAIN, www.DOMAIN myapp http://127.0.0.1:3000
api.DOMAIN myapi http://127.0.0.1:8000
  1. Create the A record for the subdomain (api → SERVER_IP).

  2. Create a new file for the subdomain: /etc/nginx/sites-available/api.DOMAIN on Ubuntu and Debian, /etc/nginx/conf.d/api.DOMAIN.conf on AlmaLinux and CentOS Stream. Put the block from step 3 in it, with server_name api.DOMAIN; and proxy_pass http://127.0.0.1:8000;. Don't copy the first site's file after certbot has modified it: it would contain the other domain's certificate. The connection-upgrade.conf file is shared, so you don't create it again.

  3. On Ubuntu and Debian, enable the site, as in step 3:

    ln -s /etc/nginx/sites-available/api.DOMAIN /etc/nginx/sites-enabled/
  4. Check the configuration, reload nginx and get the certificate:

    nginx -t
    systemctl reload nginx
    certbot --nginx -d api.DOMAIN

A service that needs another public port

Through nginx, you publish web applications: HTTP, HTTPS and WebSockets. A service with its own protocol (a game server, an MQTT broker, your own TCP or UDP service) needs a separate public port, which the network filtering blocks by default. Before you install such a service, write to support with the server's IP address, the order number (BBX-…), the port, the protocol (TCP or UDP) and what you need it for: How to contact BLACKBOX support. How the filtering works: Firewall: network filtering and ufw/firewalld on the server.

Don't ask for public ports for databases: connect through an SSH tunnel (Databases: MariaDB/MySQL and PostgreSQL).

Common problems

nginx errors are in /var/log/nginx/error.log. Show the last lines:

tail -n 50 /var/log/nginx/error.log
What you see Likely cause What to do
502 Bad Gateway; in error.log: “connect() failed (111: Connection refused) while connecting to upstream” the application isn't running, or it listens on a different port or address than the one in proxy_pass check with systemctl status myapp, ss -tlnp and journalctl -u myapp -n 50; fix the port in the application or in proxy_pass
502 Bad Gateway on AlmaLinux or CentOS Stream; in error.log: “(13: Permission denied)” SELinux is blocking the connection setsebool -P httpd_can_network_connect 1 (step 4)
504 Gateway Time-out; in error.log: “upstream timed out (110: Connection timed out)” the application takes more than 60 seconds to respond, the nginx default limit speed up the operation (for example, move it to a background job) or add proxy_read_timeout 300s; to location /, then run nginx -t and reload nginx
413 Request Entity Too Large when uploading a file the request exceeds client_max_body_size increase the value (for example, to 100m), then run nginx -t and reload nginx
After you turn on HTTPS, the application redirects to http:// or gets stuck in a redirect loop the application ignores X-Forwarded-Proto the setting from the table in step 5
In the application's logs, every visitor has the address 127.0.0.1 the application doesn't read X-Real-IP or X-Forwarded-For the setting from step 5; in Django, read the X-Real-IP header
The WebSocket doesn't connect, or closes after a minute without traffic the Upgrade and Connection headers are missing, or proxy_read_timeout has expired check the connection-upgrade.conf file and the WebSocket lines from step 3; send pings from the application or increase proxy_read_timeout
systemctl status myapp shows status=203/EXEC, status=200/CHDIR or status=217/USER the program in ExecStart, the folder in WorkingDirectory or the user in User= doesn't exist or isn't accessible check the path with command -v node or ls -l; the myapp user can't run Node.js installed with nvm in /root, so install Node.js system-wide
Django responds with 400 Bad Request the domain is missing from ALLOWED_HOSTS add DOMAIN and www.DOMAIN, then restart the service
http://SERVER_IP:3000 doesn't open in the browser the application listens on 127.0.0.1, and the network filtering blocks port 3000 this is the intended behavior: open the application at https://DOMAIN

Other reasons a site may not open: The site does not open.

Frequently asked questions

Can I use Apache instead of nginx? Yes, with Apache's proxy and proxy_http modules and the ProxyPass directive. On AlmaLinux and CentOS Stream, you need the same SELinux setting, httpd_can_network_connect. This guide covers only nginx.

Can I use PM2 instead of systemd? Yes. PM2 starts Node.js applications after the server reboots through its own systemd service, which you create with pm2 startup. Don't start the same application with both PM2 and the service from this guide: the second instance can't use the same port.

Does the application start on its own after I click “Reboot” in the panel? Yes, if the service was enabled with systemctl enable --now (step 2). The command below must print enabled. About rebooting: Rebooting, stopping and starting a server.

systemctl is-enabled myapp

Can the AI assistant do this setup? Yes, if its token has the “Rulează orice comandă ca root pe serverele mele” (Run any command as root on my servers) (servers:exec) and “Citește lista și statusul serverelor mele” (Read the list and status of my servers) (servers:read) permissions. See Example tasks for the AI assistant and the permissions they need.

Didn't find your answer?Write to us and the support team will reply. support@blackbox.md
Open a ticket