SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

Django vs Flask: Which One Fit Run for Small VPS?

See the honest RAM cost for Django and Flask on a 1 to 2 GB VPS, including RSS per gunicorn worker and how many workers your small box fit run.

Django and Flask cost for small VPS

Django vs Flask for small VPS na memory question first. Django dey load its object relational mapper (ORM), migration machinery, and, if you enable am, admin site into every worker process wey you start. Flask dey load router and request object. For 1 GB box, this difference determine how many workers fit run, and worker count determine how many requests you fit serve at the same time.

That cost only count against Django if you no rebuild wetin e give you. App wey get user accounts, sessions, and admin panel need Django: RAM wey each worker use na payment for code wey you no need write. JSON API wey dey front datastore wey you already run need Flask, because none of the batteries go ever load. Na fit question be this. The measurements below go tell you which side your app dey on.

Wan memory one gunicorn worker dey use?

ChartMemory per gunicorn worker, minimal app, three workers with preload on
The data behind this chart
[
  {
    "label": "Bare Python 3.12 process",
    "rss_mb": 14,
    "pss_mb": 9
  },
  {
    "label": "Flask, one route",
    "rss_mb": 42,
    "pss_mb": 26
  },
  {
    "label": "Flask + SQLAlchemy",
    "rss_mb": 58,
    "pss_mb": 38
  },
  {
    "label": "Django, admin disabled",
    "rss_mb": 78,
    "pss_mb": 47
  },
  {
    "label": "Django, admin enabled",
    "rss_mb": 96,
    "pss_mb": 58
  }
]

Na typical published figures be these for hello world app of each type on Ubuntu 24.04 with Python 3.12, three gunicorn workers, and preload turned on. Treat dem as minimum value, because your own imports go add to dem. One Django worker with admin enabled dey show 96 MB resident, while its proportional share of memory na 58 MB. The difference between these two numbers na the main subject of the next section.

Build this same measurement for your own system.

sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitle

Install setproctitle. When e dey available, gunicorn go rename its processes to gunicorn: master [site1] and gunicorn: worker [site1]. This one allow the next commands find the workers by name instead of guessing.

pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')

The rss column na resident set size in kilobytes: every memory page wey the process currently hold for RAM. If you add the values across the workers, the result go too high. This na because forked worker dey share pages with its parent and siblings, so dem count the same page several times. Ask the kernel for proportional set size (PSS) instead. PSS divide each shared page among the processes wey map am.

for pid in $(pgrep -f 'gunicorn: worker'); do awk -v p="$pid" '/^Pss:/ {printf "%s  %d MB\n", p, $2/1024}' /proc/$pid/smaps_rollup; done

Run am as the user wey own the workers, or use sudo. Na PSS you suppose use for budgeting, because PSS dey add correctly and RSS no dey.

Django bigger because of wetin django.setup() dey do. E imports every entry for INSTALLED_APPS, builds the application registry, and creates every model class together with one Python object for every field wey dey inside am. When you add django.contrib.admin, e runs admin autodiscovery. This imports each app's admin module and also brings in the forms and template layers. One Flask worker imports Werkzeug and Jinja2, then e stop.

One honest warning: framework often na the smaller part. Worker wey imports cloud SDK or anything numeric fit carry more memory from that than from Django. Measure your real app before you conclude say na the framework be the problem.

Copy-on-write, and why preload dey change the number

Gunicorn master process dey fork the workers. Immediately after fork(), the child dey share every memory page with the parent, and kernel go copy page only when one side write to am. So whether Django model registry dey once or four times for the server depend on which side of the fork build am.

When preload_app dey off, every worker dey import your application after dem don fork, so each one build im own private copy. When e dey on, master go import the application once, and workers go inherit those pages.

import gc

bind = "unix:/run/site1/gunicorn.sock"
umask = 0o007
workers = 3
timeout = 30
preload_app = True
max_requests = 500
max_requests_jitter = 50

def when_ready(server):
    gc.freeze()

CPython dey work against copy-on-write. Every object header get reference count, and touching object write to that header, so shared pages dey copy again one by one as garbage collector dey walk through the heap. gc.freeze() move everything wey dem don allocate so far into permanent generation wey collector no longer visit, and this keep more of those pages shared. when_ready na the correct hook because e run after preload and before dem fork the first worker. Measure PSS before and after you add am, because the saving depend on how much of your app be import-time state.

Preload get one cost wey dey surprise people on deploy day. systemctl reload dey send HUP, and gunicorn documented behaviour for HUP na to reload im configuration and start new workers. When app dey preloaded, e no re-import your code, so your new release no dey run even though worker processes dey new. Use systemctl restart after code change, or use USR2 then WINCH sequence if you need the old workers to drain first.

How many workers 1 GB VPS fit really run?

ChartWhere a 1 GB VPS goes, typical idle figures before any traffic
The data behind this chart
[
  {
    "label": "Ubuntu 24.04 base",
    "ram_mb": 190
  },
  {
    "label": "nginx",
    "ram_mb": 12
  },
  {
    "label": "PostgreSQL, default config",
    "ram_mb": 120
  },
  {
    "label": "Headroom you must leave",
    "ram_mb": 150
  },
  {
    "label": "Left for gunicorn workers",
    "ram_mb": 550
  }
]

Na idle figures be this for server wey no dey serve anything. About 550 MB remain for application workers, and this one na before the first request arrive.

Now divide am, and use pessimistic estimate. Request dey use memory while e dey run: queryset fit load few thousand rows, then template render go happen. Peak memory for one worker commonly near double the idle figure, so budget with double. Django with admin for 58 MB idle give you four workers for this box. Flask with SQLAlchemy for 38 MB give you seven.

Gunicorn (2 x cores) + 1 suggestion assume say CPU na the scarce resource and RAM no be problem. For small VPS, na opposite be that. One shared vCPU still give you less than the work of one full core when host dey busy. You need understand this before you blame your code: CPU steal time from noisy neighbour dey show for top as the st figure.

If na database or upstream API your views mostly dey wait for, threads better pass processes here. --worker-class gthread --workers 2 --threads 4 give eight concurrent requests with the memory cost of two workers, because threads dey share one loaded copy of the interpreter and framework. Global interpreter lock mean say threads no go help view wey dey use CPU heavily.

Give the box swap. 1 GB VPS wey no get swap go turn memory spike into killed process, while swapfile go turn the same spike into slow request.

sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
echo 'vm.swappiness = 10' | sudo tee /etc/sysctl.d/99-swappiness.conf
sudo sysctl --system

Then limit the application itself. MemoryMax=600M for the gunicorn unit mean say kernel go take memory back from your app's cgroup instead of choosing victim across the whole box. So, runaway request no go cost you your SSH session.

Cold start and restart behaviour

ChartImport to ready, minimal app, one shared vCPU
The data behind this chart
[
  {
    "label": "Flask, one route",
    "cold_start_ms": 90
  },
  {
    "label": "Flask + SQLAlchemy",
    "cold_start_ms": 260
  },
  {
    "label": "Django, admin disabled",
    "cold_start_ms": 480
  },
  {
    "label": "Django, admin enabled",
    "cold_start_ms": 720
  }
]

Boot cost dey happen twice: for every deploy, and for every automatic restart after crash. A minimal Flask app dey ready for about 90 ms, while Django with admin enabled dey take about 720 ms for the same shared vCPU. Both na normal published figures. Measure your own result, because your dependencies na the main factor.

cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20

The last lines list the slowest imports with cumulative microseconds. For Flask, run the same flag against your module: python -X importtime -c "import app".

With preload on, the master dey pay that cost once, and every forked worker dey start immediately. With preload off, every worker dey pay the cost, and gunicorn's timeout dey cover boot plus request handling. If worker no check in within timeout seconds, system dey kill am and replace am. Because of this, heavy app for slow shared vCPU fit enter restart loop and never serve any request. The log says:

[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)

Migrations belong inside the unit, not inside application startup code. ExecStartPre dey run once before any worker exists. If you put migrate inside your app, three workers go race one another for the same schema lock.

Deployment shape nearly the same

Process manager

Both framework dey run under gunicorn, and systemd dey run gunicorn.

[Unit]
Description=gunicorn for site1
After=network.target

[Service]
User=site1
Group=www-data
WorkingDirectory=/srv/site1
RuntimeDirectory=site1
Environment="PATH=/srv/site1/.venv/bin"
ExecStartPre=/srv/site1/.venv/bin/python manage.py migrate --noinput
ExecStart=/srv/site1/.venv/bin/gunicorn -c /srv/site1/gunicorn.conf.py site1.wsgi:application
Restart=on-failure
RestartSec=3
MemoryMax=600M

[Install]
WantedBy=multi-user.target

RuntimeDirectory=site1 dey create /run/site1 when e start and remove am when e stop, so socket path always dey there with correct owner. The umask = 0o007 line for gunicorn config na wetin make that socket writable by www-data group, and na so nginx fit reach am.

The Flask unit na the same file with one line changed: ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app, and no ExecStartPre. The app:app argument na module first, then callable. So error Failed to find attribute 'app' in 'app'. mean say your module no define variable with that name. Scheduled work fit follow the same pattern, and systemd timer fit replace cron for Django management command without adding task queue to a box this size.

Static files

Django with DEBUG = False no dey serve static files at all. Set STATIC_ROOT, run python manage.py collectstatic, and point web server to the output directory. If you skip this step, admin go load without styling while log dey fill with Not Found: /static/admin/css/base.css.

You get two reasonable ways to serve dem. An nginx alias block no dey use your app resources. WhiteNoise, wey you add as middleware, serves files from worker and saves you the nginx block, but e go use small worker time for each file. Flask serves its own static/ folder for development, and for production you point proxy to the folder for the same reason.

Reverse proxy

server {
    listen 80;
    server_name example.com;

    location /static/ {
        alias /srv/site1/static/;
        expires 30d;
    }

    location / {
        proxy_pass http://unix:/run/site1/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;
    }
}

Behind any proxy, you need tell Django say original request na HTTPS. Otherwise, its cross site request forgery (CSRF) checks go reject your own forms.

ALLOWED_HOSTS = ["example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

If the box already dey run containers, Traefik in front of several Docker Compose apps fit do the same work with labels for the container instead of one file for each site.

Which database

SQLite genuinely dey okay for one application server wey write rate na just few per second, and e remove one complete daemon from memory budget. Turn on write ahead logging (WAL) and give the driver a busy timeout.

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": BASE_DIR / "db.sqlite3",
        "OPTIONS": {
            "timeout": 20,
            "init_command": "PRAGMA journal_mode=WAL;",
        },
    }
}

Without these two options, you go meet django.db.utils.OperationalError: database is locked the first time two workers write at once. Default journal mode dey block readers during write, and default timeout dey give up almost immediately. The longer explanation, including where SQLite stop being the right answer, dey for running SQLite in production on a VPS.

PostgreSQL for the same 1 GB box costs the 120 MB wey dey for the budget above, plus one backend process for every persistent connection. Django's CONN_MAX_AGE dey keep one connection open for each worker, so four workers mean four backends. Most times, na good trade. Just count am before you set worker number. If you prefer keep database inside container beside app, running Docker on a VPS na the same trade with higher minimum cost, because daemon and each container add overhead wey matter for this size.

Wetin the batteries dey buy, and wetin dem dey cost

Django extra megabytes na list of things wey already dey exist and already dey work together: the ORM with migrations, the session and authentication system, the permission model, the form layer with CSRF protection, the template engine, management commands, and the admin. The admin na the part wey people dey underrate. E be working database editor for your models, with search and filters, for one line inside INSTALLED_APPS.

Flask na the opposite picture. You get routing, a request object, Jinja2 templates and a config object. Everything else na choice wey you go make. This get real value when the app small, because no ORM go load if you never import one.

The trap dey for the middle ground. Add SQLAlchemy for models, Alembic for migrations, Flask-Login for sessions, Flask-WTF for forms and CSRF, and an admin extension for a back office, and you don assemble something wey get Django memory profile but no get Django coherence. Every piece get its own release cycle and its own opinion about how dem suppose wire the app. Na for this point Django become the cheaper answer, both for RAM and for the hours wey you go spend on upgrades.

Django vs Flask: decision rule

Use Django when app get accounts, editable content, schema wey go continue change, and back office wey person go really open. Use Flask when app na JSON interface over datastore wey already dey exist, or webhook receiver wey no get HTML inside am.

The tiebreaker na written list. Write down every package wey you go install for Flask to reach the feature set wey you need. If that list get ORM and migration tool, you don already choose Django, and you dey pay extra to reach there slowly.

One case genuinely favour Flask for small hardware: several small services for one box. Each Flask service na im own cheap process under im own unit. Three Django sites for one 1 GB VPS mean three copies of the framework dey resident at once, and the arithmetic above no longer work. If the result keep coming out short, bigger plan often na the honest fix, and wetin VPS really cost per month na shorter conversation than rewriting application wey already dey work.

Failure mode and the strings wey you go see

Workers dey disappear then come back. Gunicorn prints [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. E dey print this both when e kill worker wey miss im timeout heartbeat and when kernel kill the process. Use dmesg -T | grep -i "killed process" take tell the two apart. If line show for there, na memory problem; reduce worker count or add swap.

Every page dey return 400 and log say Invalid HTTP_HOST header. The full message na Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS.. Django reject the request before e reach your code because ALLOWED_HOSTS empty or e no include the name wey proxy pass inside Host.

Forms fail with Origin checking failed. The page say CSRF verification failed. This one dey happen behind TLS terminating proxy: app see plain HTTP, build http:// origin, then compare am with request wey enter through https://. Set SECURE_PROXY_SSL_HEADER and CSRF_TRUSTED_ORIGINS, and confirm say proxy really dey send X-Forwarded-Proto.

nginx return 502 immediately. The error log show the reason: connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) mean say the unit no dey run, while (13: Permission denied) mean say socket dey exist but nginx no fit open am. Na umask and group setting cause this.

The admin page no get styling. collectstatic never run, or alias path no match STATIC_ROOT. The access log show 404s under /static/admin/.

Writes fail when load still low. database is locked from SQLite mean say WAL dey off, or busy timeout too short for two workers wey dey write at the same time.

FAQ

Django too heavy for a 1 GB VPS?

No. Django with small number of workers, nginx for front and SQLite for back dey run fine for 1 GB. E dey become tight when you add PostgreSQL with default settings, cache, background worker, and Docker for the same box. Measure the proportional set size of one worker, double am to allow for request peaks, then check the total against wetin remain after operating system and database.

How many gunicorn workers I suppose run for one vCPU?

Start with three and measure. Memory normally na the main constraint for small VPS, so divide the RAM wey remain after operating system and database by twice the proportional set size of one worker. If your views mostly dey wait for database or upstream API, switch to gthread worker class with small number of workers and several threads each. Threads share one loaded copy of the framework and use far less memory than extra processes.

I need PostgreSQL, or SQLite dey okay?

SQLite dey okay for one application server with modest write rate, and e removes one whole daemon from the memory budget. Enable write ahead logging and set a busy timeout, or concurrent writes go fail with database is locked. Move to PostgreSQL when more than one machine needs to write, or when you need something wey SQLite no provide, like concurrent heavy writers or per-role access control.

I suppose run uvicorn instead of gunicorn?

Only if you get async views and something real to wait for. Flask na WSGI application, so async view runs inside a fresh event loop for the worker thread and finishes before the next request starts. This no give extra concurrency. Django async views need ASGI server before dem fit gain anything. Recent uvicorn releases move their gunicorn worker class go separate package, so read the current uvicorn documentation instead of copying old worker class flag from older tutorial.

Why my worker disappear without traceback?

If kernel out of memory killer kill a process, e receives SIGKILL and no fit log anything before e comot, so your application log just stops. Gunicorn notice the gap and prints Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Confirm am with dmesg -T | grep -i "killed process". The fix na fewer workers, or a swapfile so memory spike become slow request instead of dead process.

#django#flask#python#gunicorn#deployment#vps