GuidesDevelopers and hostingDeploy a Node or Python app

Deploy a Node.js or Python web app

Your Node.js or Python app on Debian 13, under a user of its own as a hardened systemd service behind Caddy with HTTPS, deployed with a git push and reloaded without dropping requests.

Tested on Node.js 24.21.0 and Python 3.13.5 on Debian 13 (trixie) on a Melonslab server Updated October 2, 2026

Recommended server for this guide

VC-S Micro · 2 vCPU · 8 GB Memory · 250 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€7.99/mo

Deploy now
On this page

What you will set up

You have a web app that runs on your laptop. This guide puts it on a server with HTTPS, so that git push live main from your laptop is all it takes to deploy a new version.

The app runs under a user of its own, as a systemd service that starts at boot, restarts if it crashes and cannot write to its own code. Caddy sits in front, gets the HTTPS certificate and passes each visitor on to the app, which listens only on the server itself. No Docker and no process manager: Debian's own tools do the job.

Pick your app's language. Every step that differs shows the one you pick:

Runtime

With Node.js

The example is a small Express app. Node.js is developed by its contributors as a project of the OpenJS Foundation, and is open source under the MIT licence. The packages come from NodeSource's apt repository, deb.nodesource.com, which the server contacts on every apt update. npm downloads your app's dependencies from registry.npmjs.org, and by default also checks it for a newer npm, which the deploy below switches off.

With Python

The example is a small Flask app run by gunicorn, with a note for FastAPI. Python is developed by its community under the Python Software Foundation, a US non-profit, and is open source under the PSF licence. It comes from Debian's own packages. pip downloads your app's dependencies from PyPI, pypi.org, which the Python Software Foundation runs.

Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:

  • A git push from a computer elsewhere deployed a new version in about 3 seconds, and a push that failed to install its dependencies left the running version untouched.
  • During each deploy, a loop of 20 requests a second through Caddy got an answer to every request: over 220 in a row, none failed.
  • The app saw each visitor's real address over IPv4 and IPv6, and a forged X-Forwarded-For header from outside was ignored.
  • The hardened service could not write to its own code, could not read its secrets file, and could still call out to other HTTPS sites.
  • The app, Caddy and the firewall came back by themselves after a reboot, and a Node.js update arrived by itself through Debian's automatic updates.

The example Node.js app used about 70 MB of memory, the Python app about 90 MB for gunicorn with two workers, and Caddy about 20 MB.

Before you start

You need:

  • a server with Debian 13, set up as in the security guide: logins with a key, automatic security updates and ufw;
  • an A record and an AAAA record for app.example.com pointing at your server;
  • nothing else on ports 80 and 443 on the server, since Caddy takes them;
  • your app in a Git repository on your own computer.

The examples use app.example.com for your app, 203.0.113.10 for your server, 198.51.100.7 for your own address and myapp for the app's user. Replace them throughout.

1. Install the runtime

With Node.js

Debian 13 ships Node.js 20, whose support from the Node.js project ended in April 2026. NodeSource builds each Node.js release line as Debian packages, so you get the current long-term support version, 24, and its updates arrive through apt like everything else. A version manager such as nvm installs Node.js into one user's home and never updates it by itself: fine on a laptop, not on a server.

As root, add NodeSource's signing key and repository:

apt update
apt install -y curl
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key -o /etc/apt/keyrings/nodesource.asc

Create /etc/apt/sources.list.d/nodesource.sources:

Types: deb
URIs: https://deb.nodesource.com/node_24.x
Suites: nodistro
Components: main
Signed-By: /etc/apt/keyrings/nodesource.asc

Install Node.js, which brings npm with it, and Caddy and Git from Debian:

apt update
apt install -y nodejs caddy git
node -v
v24.21.0

Debian's automatic updates only install from Debian. To let them install Node.js updates too, create /etc/apt/apt.conf.d/51unattended-upgrades-nodesource:

Unattended-Upgrade::Origins-Pattern {
    "site=deb.nodesource.com";
};

Check that it is picked up:

unattended-upgrade --dry-run --debug 2>&1 | grep 'Allowed origins'

The line ends with site=deb.nodesource.com.

With Python

Debian's Python gets security updates from Debian's security team, which install by themselves on a server set up as in the security guide. Your app's own packages go in a virtual environment (venv), a directory of their own, so they never mix with Debian's.

As root:

apt update
apt install -y python3-venv caddy git curl
python3 -V
Python 3.13.5

2. Create the app's user

The app gets a user of its own, and its code lives in that user's home. The user's shell is git-shell, so the key that can push code to it can do nothing else: no shell, no other commands.

As root:

useradd -m -s /usr/bin/git-shell myapp
install -d -m 700 -o myapp -g myapp /home/myapp/.ssh
install -m 600 -o myapp -g myapp /root/.ssh/authorized_keys /home/myapp/.ssh/authorized_keys
runuser -u myapp -- git init --bare -b main /home/myapp/app.git
runuser -u myapp -- mkdir /home/myapp/app

The second and third lines let the keys that log in as root push code as myapp. app.git is a bare repository: it holds your pushes. app holds the checked-out code that runs.

With Python

Create the venv for the app:

runuser -u myapp -- python3 -m venv /home/myapp/venv

3. Prepare your app

Three things in your app make it fit this setup: it listens on 127.0.0.1 only, so nothing reaches it except through Caddy; it trusts the visitor's address that Caddy passes on; and it finishes open requests when it is told to stop.

With Node.js

The example app, server.js:

const express = require('express');

const app = express();
// Caddy on this server passes the visitor's address in X-Forwarded-For.
app.set('trust proxy', 'loopback');

app.get('/', (req, res) => {
  res.send(`Hello from Node.js ${process.version}. Your address: ${req.ip}, via ${req.protocol}\n`);
});

const port = process.env.PORT || 3000;
const server = app.listen(port, '127.0.0.1', () => {
  console.log(`Listening on 127.0.0.1:${port}`);
});

// On a restart, finish the requests that are open before exiting.
process.on('SIGTERM', () => {
  console.log('SIGTERM: finishing open requests');
  server.close(() => process.exit(0));
});

trust proxy set to loopback makes req.ip the visitor's address when the request comes from Caddy on the same server, and ignores the header from anywhere else. npm ci on the server installs exactly what package-lock.json lists, so commit that file.

With Python

The example app, app.py:

import sys

from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
# Caddy on this server passes the visitor's address in X-Forwarded-For.
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)


@app.get("/")
def index():
    return f"Hello from Python {sys.version.split()[0]}. Your address: {request.remote_addr}, via {request.scheme}\n"

And requirements.txt:

flask==3.1.*
gunicorn==26.*

gunicorn trusts X-Forwarded-Proto from 127.0.0.1 by default, so the app knows the visitor came over HTTPS, but it does not take the visitor's address from X-Forwarded-For. Without ProxyFix, the app sees every visitor as 127.0.0.1. gunicorn already finishes open requests when it stops.

FastAPI: run it in gunicorn with uvicorn's worker. Add uvicorn-worker to requirements.txt, and in step 5 replace app:app with -k uvicorn_worker.UvicornWorker main:app. uvicorn trusts the headers from 127.0.0.1 by default, so you need no ProxyFix. This was tested with FastAPI 0.142 under the same service, including the reload in step 10.

4. Keep secrets in an environment file

Passwords and API keys stay out of Git, in a file only root can read. systemd reads it when it starts the app and passes the values on as environment variables. As root:

With Node.js

install -m 600 /dev/null /etc/myapp.env
printf 'PORT=3000\nSESSION_SECRET=%s\n' "$(openssl rand -hex 32)" >> /etc/myapp.env

With Python

install -m 600 /dev/null /etc/myapp.env
printf 'SECRET_KEY=%s\n' "$(openssl rand -hex 32)" >> /etc/myapp.env

Add your own lines in the same NAME=value form. The app reads them as usual (process.env.NAME in Node.js, os.environ["NAME"] in Python), but the myapp user cannot open the file itself.

5. Run the app as a service

With Node.js

Create /etc/systemd/system/myapp.service:

[Unit]
Description=myapp (Node.js)
After=network.target

[Service]
User=myapp
Group=myapp
WorkingDirectory=/home/myapp/app
EnvironmentFile=/etc/myapp.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=2

# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
UMask=0077

[Install]
WantedBy=multi-user.target

Leave out MemoryDenyWriteExecute=yes, which other hardening lists include: Node.js compiles JavaScript into machine code as it runs, and crashes at once with it.

With Python

Create /etc/systemd/system/myapp.service:

[Unit]
Description=myapp (Python)
After=network.target

[Service]
User=myapp
Group=myapp
WorkingDirectory=/home/myapp/app
EnvironmentFile=/etc/myapp.env
ExecStart=/home/myapp/venv/bin/gunicorn --bind 127.0.0.1:8000 --workers 2 --no-control-socket app:app
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure
RestartSec=2

# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
UMask=0077

[Install]
WantedBy=multi-user.target

app:app means the object app in app.py. --no-control-socket switches off the control socket that gunicorn 26 opens in the user's home, which the read-only home would turn into an error at every start. ExecReload lets systemctl reload reload the app gracefully, as step 10 shows.

Restart=on-failure starts the app again if it crashes: killed on purpose, it was back 2 seconds later. The hardening block takes nothing the app needs, and systemd-analyze security myapp rates the service 1.7 to 1.8 instead of 9.2 out of 10, where lower means less exposed:

  • ProtectSystem=strict and ProtectHome=read-only make the whole file system read-only for the app, its own code included, so a hole in the app cannot plant code that survives a restart. If your app needs to write files, create a directory such as /home/myapp/data owned by myapp, and add ReadWritePaths=/home/myapp/data.
  • NoNewPrivileges, the empty CapabilityBoundingSet and SystemCallFilter stop the app from gaining privileges or using the system calls meant for administration.
  • RestrictAddressFamilies still allows ordinary network connections, so the app can call APIs on the internet.

Enable it, without starting it yet, since there is no code until the first push:

systemctl daemon-reload
systemctl enable myapp

6. Deploy with git push

A hook in the bare repository runs after every push to main: it checks out the code, installs the dependencies and restarts the app.

To let the hook restart the app without root, allow myapp exactly one command. Create /etc/sudoers.d/myapp:

myapp ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart myapp.service
chmod 440 /etc/sudoers.d/myapp
visudo -c

visudo -c ends with /etc/sudoers.d/myapp: parsed OK.

With Node.js

Create /home/myapp/app.git/hooks/post-receive:

#!/bin/sh
# Deploy every push to main: check out the code, install dependencies, restart.
set -e
while read -r old new ref; do
  [ "$ref" = refs/heads/main ] || continue
  git --work-tree="$HOME/app" checkout -f -q main
  cd "$HOME/app"
  npm ci --omit=dev --no-audit --no-fund --no-update-notifier
  sudo systemctl reload-or-restart myapp.service
  echo "Deployed $new"
done

With Python

Create /home/myapp/app.git/hooks/post-receive:

#!/bin/sh
# Deploy every push to main: check out the code, install dependencies, reload.
set -e
while read -r old new ref; do
  [ "$ref" = refs/heads/main ] || continue
  git --work-tree="$HOME/app" checkout -f -q main
  cd "$HOME/app"
  "$HOME/venv/bin/pip" install -q -r requirements.txt
  sudo systemctl reload-or-restart myapp.service
  echo "Deployed $new"
done
chown myapp:myapp /home/myapp/app.git/hooks/post-receive
chmod 755 /home/myapp/app.git/hooks/post-receive

reload-or-restart starts the app on the first push and reloads or restarts it after that. set -e stops the hook at the first error, so if the dependencies fail to install, the running version stays as it is.

Now, on your own computer, in your app's repository, add the server as a remote and push:

git remote add live myapp@app.example.com:app.git
git push live main

With Node.js

remote: added 68 packages in 2s
remote: Deployed 6f3dbefd446c5710ef8d159e346792a021949777
To app.example.com:app.git
 * [new branch]      main -> main

With Python

remote: Deployed 5bf1717456588772fdf3bdd2f91182806d035cb4
To app.example.com:app.git
 * [new branch]      main -> main

If your branch is called master, push it with git push live master:main. From now on, git push live main is the whole deploy.

Back on the server, check that the app answers locally:

With Node.js

systemctl is-active myapp
curl -s http://127.0.0.1:3000/
active
Hello from Node.js v24.21.0. Your address: 127.0.0.1, via http

With Python

systemctl is-active myapp
curl -s http://127.0.0.1:8000/
active
Hello from Python 3.13.5. Your address: 127.0.0.1, via http

7. Put Caddy in front

Replace /etc/caddy/Caddyfile with this. The email address is the one Let's Encrypt writes to if a certificate has a problem:

With Node.js

{
    email you@example.com
}

app.example.com {
    reverse_proxy 127.0.0.1:3000 {
        # While the app restarts, hold requests for up to 5 seconds instead of answering 502.
        lb_try_duration 5s
    }
}

With Python

{
    email you@example.com
}

app.example.com {
    reverse_proxy 127.0.0.1:8000 {
        # While the app restarts, hold requests for up to 5 seconds instead of answering 502.
        lb_try_duration 5s
    }
}
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy

Caddy gets the certificate within seconds. journalctl -u caddy | grep 'certificate obtained' shows it. More apps on the same server each get a block of their own, with their own name and port.

The firewall needs ports 80 and 443 open, and HTTP/3 uses UDP 443. If you followed the security guide, ufw status already lists them. If not:

ufw allow 22/tcp
ufw allow 80,443/tcp
ufw allow 443/udp
ufw enable

Don't open the app's own port: it listens on 127.0.0.1 and stays out of reach either way.

8. Check the visitor's address

On your own computer:

curl https://app.example.com/

With Node.js

Hello from Node.js v24.21.0. Your address: 198.51.100.7, via https

With Python

Hello from Python 3.13.5. Your address: 198.51.100.7, via https

Your own address and https mean the app trusts Caddy's headers. Over IPv6, the app shows your IPv6 address. Now try to forge the header:

curl -H 'X-Forwarded-For: 192.0.2.1' https://app.example.com/

The app still shows your real address: Caddy replaces the header that comes from outside instead of passing it on.

9. Read the logs

Everything the app writes to its output goes to the journal. As root:

journalctl -u myapp -f

-f follows new lines as they come, Ctrl+C stops. journalctl -u myapp --since today shows today's, and journalctl -u myapp -b everything since the last boot, deploys and crashes included. journalctl -u caddy holds Caddy's, certificates included.

10. Deploy without downtime

A restart stops the app and starts it again. Between the two, nothing listens on its port, and Caddy answers 502 Bad Gateway. To measure it, we sent 20 requests a second through Caddy while deploying.

With Node.js

A single Node.js process cannot hand its port over to a new one, so every deploy is a restart. What you can do is make it invisible:

  • Without lb_try_duration in the Caddyfile, 8 of 228 requests got 502 during a deploy: a gap of about 0.4 seconds.
  • With lb_try_duration 5s, Caddy holds new requests until the new process listens: 222 of 222 got an answer, none failed.
  • The SIGTERM handler in server.js lets requests that are already open finish. Without it, Node.js exits at once: a 3-second POST request sent just before a restart got 502. Caddy sends a cut-off GET again by itself, but not a POST. With the handler, the same POST finished normally.

If your app holds connections open for a long time, such as WebSockets or server-sent events, server.close waits for them, and systemd ends the process after 90 seconds, its default (not tested here). Those connections drop with every deploy, and the client has to reconnect.

Running several processes to take turns would close that last gap, but it needs a process manager or a second service and a load balancer setup, which is more than most apps need.

With Python

gunicorn can reload without a gap. On systemctl reload myapp, which the hook runs, the gunicorn master gets the HUP signal, starts new workers with the new code, and lets the old ones finish their requests before they exit. Its port stays open all the time.

  • A systemctl restart myapp, without lb_try_duration in the Caddyfile: 11 of 126 requests got 502.
  • A systemctl reload myapp: 140 of 140 got an answer.
  • A git push of a new version: 245 of 245 got an answer, and the new text showed straight after.

The journal shows the reload:

[INFO] Hang up: Master
Reloaded myapp.service - myapp (Python).
[INFO] Booting worker with pid: 16461
[INFO] Booting worker with pid: 16463
[INFO] Worker exiting (pid: 15393)
[INFO] Worker exiting (pid: 15394)

A reload loads your app's code and packages again, but not gunicorn itself, which runs in the master. After you update gunicorn, or Python as in step 12, use systemctl restart myapp. lb_try_duration in the Caddyfile hides that restart too.

To measure it yourself, run this on the server while you push from your own computer. It counts the answers by status code:

for i in $(seq 300); do curl -s -o /dev/null -w '%{http_code}\n' https://app.example.com/ & sleep 0.05; done | sort | uniq -c

11. Test a reboot

systemctl reboot

Log in again and check:

systemctl is-active myapp caddy
curl -s https://app.example.com/

Both are active, and the app answers through Caddy. ufw starts by itself with its rules.

12. Update the runtime

With Node.js

Updates within Node.js 24 install by themselves, with Debian's, through the file from step 1. The running app keeps the old version until it restarts. To check whether it needs one:

ls -l /proc/$(systemctl show -p MainPID --value myapp)/exe
lrwxrwxrwx 1 myapp myapp 0 Oct  2 19:05 /proc/17446/exe -> /usr/bin/node (deleted)

(deleted) means a newer Node.js is installed. Restart the app:

systemctl restart myapp

To move to the next long-term support line, such as 26, change node_24.x to node_26.x in /etc/apt/sources.list.d/nodesource.sources, then:

apt update
apt install -y nodejs

Then push an empty commit from your own computer, so that npm ci reinstalls the dependencies for the new version and the app restarts:

git commit --allow-empty -m "Redeploy on Node.js 26"
git push live main

Switching from 24 to 26 and back was tested this way.

With Python

Security updates to Python install by themselves with Debian's. The running app keeps the old version until it restarts. To check whether it needs one:

ls -l /proc/$(systemctl show -p MainPID --value myapp)/exe

If the link ends in (deleted), a newer Python is installed. Restart the app:

systemctl restart myapp

Your app's own packages update when you change requirements.txt and push.

The next Debian release brings a new Python version, and a venv only works with the version it was made with. After the upgrade, make the venv again as root:

rm -rf /home/myapp/venv
runuser -u myapp -- python3 -m venv /home/myapp/venv

Then push an empty commit from your own computer, so that pip installs the packages again:

git commit --allow-empty -m "Rebuild venv"
git push live main

Finish with systemctl restart myapp on the server, since a reload does not replace gunicorn itself. Making the venv again and pushing was tested. A Debian release upgrade was not.

Troubleshooting

The push prints sudo: unable to resolve host and a server name. The server's name is missing from /etc/hosts. The deploy still works. To fix it, as root: sed -i "s/^127\.0\.1\.1.*/127.0.1.1 $(hostname)/" /etc/hosts.

The push is accepted, but there is no Deployed line, and the site shows the old version. Something in the hook failed, usually installing the dependencies, and the lines above it show the error. The old version keeps running, but the new code is already checked out, so fix the problem and push again before the app restarts for another reason.

fatal: Interactive git shell is not enabled. You logged in as myapp with ssh. That user only accepts Git. Log in as root for everything else.

The site answers 502 Bad Gateway for more than a few seconds. The app is not running. systemctl status myapp and journalctl -u myapp -n 50 show why. A crash in a loop shows as restart counter is at with a rising number.

The app crashes with Read-only file system. It tries to write somewhere other than /tmp. Give it a directory with ReadWritePaths=, as in step 5, then systemctl daemon-reload and systemctl restart myapp.

gunicorn logs Control server error: [Errno 30] Read-only file system: '/home/myapp/.gunicorn'. --no-control-socket is missing from ExecStart. gunicorn 26 opens a control socket in the user's home, which the service cannot write to.

The app sees every visitor as 127.0.0.1. It does not trust Caddy's headers: trust proxy in Express, ProxyFix in Flask, as in step 3.

Run it on your own server

VC-S Micro

€7.99/mo

vCPU
2
Memory
8 GB
Storage
250 GB
Transfer
10 TB
Standard
HDD · RAID 10
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides