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.
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 nginxAlmaLinux and CentOS Stream:
dnf install -y nginx
systemctl enable --now nginxMore 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, without127.0.0.1, can't be closed withufworfirewalld. Publish containers only on127.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.
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 myappCopy 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 thepython3-venvpackage first; on AlmaLinux and CentOS Stream the module is already included.apt install -y python3-venvpython3 -m venv /srv/myapp/venv /srv/myapp/venv/bin/pip install -r /srv/myapp/requirements.txt gunicornFor FastAPI, install
uvicorninstead ofgunicorn.Make the
myappuser the owner of the files:chown -R myapp:myapp /srv/myappPut the environment variables and secrets (passwords, API keys) in a separate file. On AlmaLinux and CentOS Stream, if the
nanoeditor is missing, install it first.dnf install -y nanonano /etc/myapp.envWrite one variable per line, in the form
NAME=value, withoutexport. 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_VALUEMake 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.envCreate 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.targetIn the
ExecStartline, write the full path of the program. For Node.js, find it withcommand -v node. Examples for other applications (replaceproject.wsgi,app:appandmain:appwith your application's module):Application ExecStart=Node.js /usr/bin/node /srv/myapp/server.jsDjango with gunicorn /srv/myapp/venv/bin/python -m gunicorn --workers 3 --bind 127.0.0.1:8000 project.wsgi:applicationFlask with gunicorn /srv/myapp/venv/bin/python -m gunicorn --workers 3 --bind 127.0.0.1:8000 app:appFastAPI with uvicorn /srv/myapp/venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000Load the new service, enable it (so it also starts after the server reboots) and check it.
systemctl statusmust showactive (running).systemctl daemon-reload systemctl enable --now myapp systemctl status myappFollow 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 myappSecurity 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 thesystemctl showcommand. Keep secrets in the file protected withchmod 600.
Step 3. Configuring nginx
Create the WebSockets file once per server, however many applications you have:
nano /etc/nginx/conf.d/connection-upgrade.confmap $http_upgrade $connection_upgrade { default upgrade; '' close; }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; } }On Ubuntu and Debian only, enable the site:
ln -s /etc/nginx/sites-available/DOMAIN /etc/nginx/sites-enabled/Check the configuration and reload nginx.
nginx -tmust print “syntax is ok” and “test is successful”; otherwise, fix the file before you reload.nginx -t systemctl reload nginxOpen
http://DOMAINin 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_connectThe 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/tcpAlmaLinux and CentOS Stream (firewalld):
firewall-cmd --permanent --add-service=http --add-service=https
firewall-cmd --reloadWarning 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
Install certbot with the nginx plugin, as described in Web server with HTTPS: nginx or Apache and Let's Encrypt.
Get the certificate:
certbot --nginx -d DOMAIN -d www.DOMAINcertbot 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.Open
https://DOMAINand 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 |
Create the A record for the subdomain (
api→SERVER_IP).Create a new file for the subdomain:
/etc/nginx/sites-available/api.DOMAINon Ubuntu and Debian,/etc/nginx/conf.d/api.DOMAIN.confon AlmaLinux and CentOS Stream. Put the block from step 3 in it, withserver_name api.DOMAIN;andproxy_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. Theconnection-upgrade.conffile is shared, so you don't create it again.On Ubuntu and Debian, enable the site, as in step 3:
ln -s /etc/nginx/sites-available/api.DOMAIN /etc/nginx/sites-enabled/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 myappCan 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.