Host Multiple Django Sites on One Server

Run several Django projects on one server safely: separate users and virtualenvs, systemd templates, per-site databases, and Nginx blocks.

Problem statement

Agencies, freelancers, and small teams often run several Django projects on one VPS: a marketing site, a client portal, a staging copy. It is cost effective, but sites on a shared host interfere with each other in ways a single-site setup never shows:

  • one site's dependency upgrade breaks another because they share a virtualenv or system packages
  • a compromised or buggy site can read another site's secrets and uploads
  • requests for an unknown hostname land on whichever site Nginx considers the default
  • two apps writing to the same Redis database overwrite each other's cache keys
  • one memory-hungry site pushes the server into swap and slows every site

This guide sets up a repeatable pattern where each Django site gets its own Linux user, virtualenv, Gunicorn service, socket, database, and Nginx server block, with a catch-all that rejects unknown hosts.

Several Django sites on one serverNginxserver_name routinggunicorn@site1own user and socketgunicorn@site2own user and socketPostgreSQLone database per sitePer-site users, sockets, and databases keep one site's problem from reaching the others.

Quick answer

  1. Create one system user and home directory per site under /srv/<site>.
  2. Give each site its own virtualenv, environment file, and PostgreSQL database and role.
  3. Use a systemd template unit (gunicorn@.service) so each site runs as gunicorn@<site> with its own socket in /run/<site>/.
  4. Give each site an Nginx server block with its own server_name, and add a default_server block that closes connections for unknown hosts.
  5. Separate Redis usage by database number or key prefix, or use one Redis per site.
  6. Budget memory: sum each site's workers × per-worker memory, and cap services with MemoryMax.
  7. Verify isolation: file permissions, unknown-host behavior, and per-site restarts.

Step-by-step solution

1. Directory and user layout

For each site (here shop and portal):

for site in shop portal; do
  sudo adduser --system --group --home /srv/$site $site
  sudo chmod 750 /srv/$site
  sudo -u $site python3 -m venv /srv/$site/venv
  sudo -u $site git clone https://github.com/example/$site.git /srv/$site/app
  sudo -u $site /srv/$site/venv/bin/pip install -r /srv/$site/app/requirements.txt gunicorn
done
one user, one directory, one socket per siteLinux user per siteshop, blog, crm/srv/<site>/app and venvown code and dependencies/etc/sites/<site>/envown secrets, root-owned/run/<site>/gunicorn.sockown socket, own processOwn database and roleno shared credentialsA compromise of one site cannot read another’s filesdjango-deployment.com
Separate users are the cheapest isolation Linux offers.

Mode 750 on each home directory means one site's user cannot read another site's code, uploads, or files.

Nginx still needs to read each site's static files. Collect them into a directory Nginx can traverse, for example /srv/static/<site>:

sudo install -d -m 755 /srv/static
for site in shop portal; do
  sudo install -d -o $site -g $site -m 755 /srv/static/$site
done

Set STATIC_ROOT = "/srv/static/shop" in the shop settings, and so on.

2. Per-site configuration and database

sudo install -d -m 750 -o root -g shop /etc/sites/shop
sudo tee /etc/sites/shop/env >/dev/null <<'EOF'
DJANGO_SETTINGS_MODULE=config.settings
DJANGO_SECRET_KEY=generated-shop-secret
DJANGO_ALLOWED_HOSTS=shop.example.com
DATABASE_URL=postgres://shop:shop-db-password@127.0.0.1:5432/shop
REDIS_URL=redis://127.0.0.1:6379/1
EOF
sudo chmod 640 /etc/sites/shop/env

Create a database and role per site:

sudo -u postgres psql <<'SQL'
CREATE ROLE shop LOGIN PASSWORD 'shop-db-password';
CREATE DATABASE shop OWNER shop;
REVOKE CONNECT ON DATABASE shop FROM PUBLIC;
SQL

REVOKE CONNECT ... FROM PUBLIC prevents other sites' roles from connecting to this database. Repeat for each site with different passwords.

3. One systemd template for all sites

/etc/systemd/system/gunicorn@.service:

[Unit]
Description=Gunicorn for %i
After=network.target postgresql.service

[Service]
User=%i
Group=www-data
WorkingDirectory=/srv/%i/app
EnvironmentFile=/etc/sites/%i/env
RuntimeDirectory=%i
ExecStart=/srv/%i/venv/bin/gunicorn config.wsgi:application \
    --bind unix:/run/%i/gunicorn.sock \
    --workers 2 \
    --access-logfile -
Restart=on-failure
MemoryMax=600M
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target
one unit file, many sites# /etc/systemd/system/gunicorn@.serviceUser=%iWorkingDirectory=/srv/%i/appEnvironmentFile=/etc/sites/%i/envExecStart=/srv/%i/venv/bin/gunicorn … --bind unix:/run/%i/gunicorn.sock1231A template: start gunicorn@shop, gunicorn@blog.2Replaced by the instance name.3A socket per site for its Nginx block.Adding a site is a user, a directory, an env file, and systemctl enabledjango-deployment.com
Per-site overrides go in gunicorn@shop.service.d/ drop-ins.

%i is replaced by the instance name, so gunicorn@shop runs as user shop, reads /etc/sites/shop/env, and creates /run/shop/gunicorn.sock. The socket's group is www-data, so Nginx can connect.

If sites need different worker counts, add a per-site drop-in:

sudo systemctl edit gunicorn@portal
[Service]
ExecStart=
ExecStart=/srv/portal/venv/bin/gunicorn config.wsgi:application \
    --bind unix:/run/portal/gunicorn.sock --workers 4 --access-logfile -

The empty ExecStart= line clears the template's command before setting a new one.

Start the sites:

sudo systemctl daemon-reload
sudo systemctl enable --now gunicorn@shop gunicorn@portal
systemctl list-units 'gunicorn@*'

4. Nginx server blocks

/etc/nginx/sites-available/shop:

server {
    listen 80;
    server_name shop.example.com;

    location /static/ {
        alias /srv/static/shop/;
    }

    location / {
        proxy_pass http://unix:/run/shop/gunicorn.sock;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Create the same for each site with its own server_name, socket, and static path.

5. Reject unknown hostnames

Without an explicit default, Nginx sends requests for unknown Host values (for example scanners hitting the IP address) to the first server block for that port. Django then logs DisallowedHost errors, or worse, a site answers for a domain it does not own. Add a catch-all:

/etc/nginx/sites-available/000-default:

server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;
    return 444;
}

server {
    listen 443 ssl default_server;
    listen [::]:443 ssl default_server;
    server_name _;
    ssl_reject_handshake on;
}

return 444 is an Nginx-specific code that closes the connection without a response. ssl_reject_handshake on (Nginx 1.19.4 and later) rejects TLS connections for unknown server names without needing a certificate.

sudo ln -s /etc/nginx/sites-available/{000-default,shop,portal} /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

6. HTTPS for every site

sudo certbot --nginx -d shop.example.com
sudo certbot --nginx -d portal.example.com

Separate certificates per site keep renewals and domain changes independent.

7. Shared services without collisions

Redis. Give each site a different database number (/1, /2) and set a cache key prefix, because FLUSHDB in one database only clears that database, but FLUSHALL clears everything:

CACHES = {
    "default": {
        "BACKEND": "django.core.cache.backends.redis.RedisCache",
        "LOCATION": os.environ["REDIS_URL"],
        "KEY_PREFIX": "shop",
    }
}
shared services, separate namespacesKeep sites apart withPostgreSQL✓a database and role per siteRedis cacheKEY_PREFIX per site, or DB numberCeleryseparate broker DB or vhost and queue namesMedia storagea directory or bucket prefix per siteTwo sites flushing one Redis cache is a classic surprisedjango-deployment.com
Default Celery queue names collide between sites on one broker.

For strict isolation or different eviction policies, run separate Redis instances instead.

Celery. Use a separate queue namespace or broker database per site, and run a separate worker service per site (for example a celery@.service template mirroring the Gunicorn one).

Cron and timers. Name them per site and run them as the site user.

8. Memory budget

Estimate before adding a site:

ps -o pid,rss,cmd -u shop | grep gunicorn
free -m
budget memory per siteshop: 4 workers × 200 MB0.8 GBblog: 2 workers × 150 MB0.3 GBcrm: 3 workers × 250 MB0.75 GBPostgreSQL, Redis, OS1.4 GBTotal of 4 GB3.25 GB0 GB1 GB2 GB3 GB4 GBGBIllustrative numbers: leave headroom for traffic spikes and deploysdjango-deployment.com
A quiet site still needs one warm worker.

Add up resident memory for every worker across all sites, plus PostgreSQL, Redis, and Nginx, and keep at least 20 to 30 percent free for page cache and spikes. MemoryMax in the unit prevents one site from consuming everything; when a site hits its limit, the kernel's out-of-memory killer acts inside that service's control group, so only that site's processes are killed.

9. Deploying one site

site=shop
cd /srv/$site/app
sudo -u $site git pull
sudo -u $site /srv/$site/venv/bin/pip install -r requirements.txt
sudo -u $site bash -c "set -a; . /etc/sites/$site/env; set +a; \
  /srv/$site/venv/bin/python manage.py migrate --noinput && \
  /srv/$site/venv/bin/python manage.py collectstatic --noinput"
sudo systemctl restart gunicorn@$site

Only the named site restarts. Other sites keep their own dependency versions, so upgrading Django for one site does not touch the rest.

10. Verify isolation

# Site users cannot read each other's files
sudo -u shop ls /srv/portal/app

# Unknown host is dropped
curl -sv http://SERVER_IP/ -H "Host: unknown.example" 2>&1 | tail -3

# Each site responds on its own domain
curl -sI https://shop.example.com/ | head -1
curl -sI https://portal.example.com/ | head -1

# Restarting one site leaves the other up
sudo systemctl restart gunicorn@shop && curl -sI https://portal.example.com/ | head -1

Verification check:

  • the ls fails with "Permission denied"
  • the unknown host gets an empty reply
  • both sites return their expected status
  • systemctl status gunicorn@portal shows no restart when shop is restarted

Rollback

Each site rolls back independently: check out the previous release in that site's directory (or switch its current symlink), then restart only that site's unit.

Explanation

The template pattern turns "site" into a single parameter: user, paths, socket, environment file, and memory limit all derive from the instance name. That makes adding a site mechanical and keeps sites from sharing anything they should not, while still sharing the expensive pieces (Nginx, PostgreSQL server, Redis server) efficiently.

Edge cases / notes

Different Python versions per site

Install additional interpreters (for example with uv or deadsnakes) and create each virtualenv with the version that site needs. The systemd template does not care which Python the virtualenv uses.

Sites on subpaths of one domain

Serving two Django apps under /a/ and /b/ of one domain requires FORCE_SCRIPT_NAME, distinct SESSION_COOKIE_NAME and CSRF_COOKIE_NAME values, and path-scoped cookies. Separate subdomains are much simpler.

When to split servers

If one site needs frequent restarts, much more memory, or a different security posture, move it to its own server; the per-site layout makes that migration straightforward.

Troubleshooting shared servers

Finding which site is using resources

systemd's control groups make per-site accounting easy:

systemd-cgtop -m            # live memory per unit
systemctl status 'gunicorn@*' --no-pager | grep -E '●|Memory|Tasks'
journalctl -u gunicorn@shop --since "1 hour ago" | tail -50

For database load, group PostgreSQL activity by role, since each site has its own:

SELECT usename, state, count(*)
FROM pg_stat_activity
GROUP BY usename, state
ORDER BY count(*) DESC;

Common failure patterns

SymptomCauseFix
One domain shows another site's contentmissing or mistyped server_name, request fell to another blockcorrect server_name; keep the default_server catch-all
502 on one site after a deploythat site's service failedsystemctl status gunicorn@site, journalctl -u gunicorn@site
All sites slow at the same timememory pressure or a shared database overloadedfree -m, systemd-cgtop, check slow queries per role
Certbot renews one domain but not anotherDNS for that domain moved or port 80 blockedcertbot renew --dry-run, check DNS
Static files 403 for a new siteNginx cannot traverse the static directorynamei -l /srv/static/site/css/app.css

Backups per site

Back up each site as a unit so you can restore one without touching the others:

site=shop
sudo -u postgres pg_dump --format=custom "$site" > "/var/backups/$site/db-$(date +%F).dump"
sudo tar -czf "/var/backups/$site/media-$(date +%F).tar.gz" -C "/srv/$site" media

Copy the backup directory off the server, and keep retention per site according to each client's requirements.

Onboarding checklist for a new site

  1. Create the user, home directory with mode 750, and virtualenv.
  2. Create the database, role, and REVOKE CONNECT ... FROM PUBLIC.
  3. Write /etc/sites/<site>/env with mode 640, group set to the site user.
  4. Choose a Redis database number or key prefix and record it.
  5. Enable gunicorn@<site> and check memory budget.
  6. Add the Nginx server block, reload, and issue a certificate.
  7. Add backups and monitoring for the new domain.

Keeping this as a written checklist or script prevents a site from being added without isolation or backups.

Next steps

FAQ

Can multiple Django projects share one virtualenv?

They can, but they should not. Shared dependencies force every site to upgrade together and make one site's upgrade a risk for all of them. One virtualenv per site costs little disk space.

How do I run Gunicorn for several Django sites with systemd?

Use a template unit such as gunicorn@.service with %i in the user, paths, and socket, then start gunicorn@site1, gunicorn@site2, and so on.

Why do I see DisallowedHost errors for domains I do not own?

Requests for unknown hostnames reach the first matching server block. Add a default_server block that returns 444 for HTTP and rejects TLS handshakes for unknown names.

Should each Django site have its own PostgreSQL database?

Yes, with its own role and password, and with CONNECT revoked from PUBLIC. This keeps a problem in one site from exposing or damaging another site's data.

Keep going