Django vs Flask on a small VPS
What Django and Flask really cost on a 1 to 2 GB VPS: resident memory per gunicorn worker, and how many workers a small box can honestly run.
What Django and Flask cost on a small VPS
Django vs Flask on a small VPS is a memory question first. Django loads its object relational mapper (ORM), its migration machinery and, if you enable it, the admin site into every worker process you start. Flask loads a router and a request object. On a 1 GB box that difference sets how many workers fit, and worker count sets how many requests you can serve at the same time.
That cost only counts against Django if you never rebuild what it gave you. An app with user accounts, sessions and an admin panel wants Django: the RAM per worker is the price of code you do not write. A JSON API in front of a datastore you already run wants Flask, because none of the batteries would ever load. This is a fit question. The measurements below tell you which side your app falls on.
How much memory does one gunicorn worker use?
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
}
]Those are typical published figures for a hello world app of each shape on Ubuntu 24.04 with Python 3.12, three gunicorn workers and preload turned on. Treat them as a floor, because your own imports sit on top of them. A Django worker with the admin enabled shows 96 MB resident while its proportional share of memory is 58 MB. The gap between those two numbers is the whole subject of the next section.
Build the same measurement on your own box.
sudo apt update && sudo apt install -y python3-venv
python3 -m venv /srv/site1/.venv
/srv/site1/.venv/bin/pip install django gunicorn setproctitleInstall setproctitle. With it present, gunicorn renames its processes to gunicorn: master [site1] and gunicorn: worker [site1], which is what lets the next commands find the workers by name instead of by guessing.
pgrep -af gunicorn
ps -o pid,rss,args -p $(pgrep -d, -f 'gunicorn: worker')The rss column is resident set size in kilobytes: every page of memory the process currently holds in RAM. Adding it up across workers gives a number that is too high, because a forked worker shares pages with its parent and with its siblings, so the same page is counted several times. Ask the kernel for the proportional set size (PSS) instead, which divides each shared page across the processes that map it.
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; doneRun it as the user that owns the workers, or with sudo. PSS is the column to budget against, because PSS sums correctly and RSS does not.
Django is bigger because of what django.setup() does. It imports every entry in INSTALLED_APPS, builds the application registry, and instantiates every model class along with a Python object for every field on it. Adding django.contrib.admin runs admin autodiscovery, which imports each app's admin module and drags the forms and template layers in behind it. A Flask worker imports Werkzeug and Jinja2, and stops.
One honest caveat: the framework is often the small part. A worker that imports a cloud SDK or anything numeric carries more of that than of Django. Measure your real app before you decide the framework is the problem.
Copy on write, and why preload changes the number
Gunicorn's master process forks the workers. Straight after fork() the child shares every memory page with the parent, and the kernel copies a page only when one side writes to it. So whether Django's model registry exists once or four times on the box depends on which side of the fork it was built on.
With preload_app off, each worker imports your application after it has been forked, so each one builds its own private copy. With it on, the master imports the application once and the workers 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 works against copy on write. Every object header holds a reference count, and touching an object writes to that header, so shared pages get copied back one at a time as the garbage collector walks the heap. gc.freeze() moves everything allocated so far into a permanent generation that the collector no longer visits, which keeps more of those pages shared. when_ready is the correct hook because it runs after the preload and before the first worker is forked. Measure PSS before and after you add it, since the saving depends on how much of your app is import time state.
Preload has one cost that surprises people on deploy day. systemctl reload sends HUP, and gunicorn's documented behaviour on HUP is to reload its configuration and start new workers. When the app is preloaded it does not re-import your code, so your new release is not running even though the worker processes are new. Use systemctl restart after a code change, or the USR2 then WINCH sequence if you need the old workers to drain first.
How many workers can a 1 GB VPS honestly run?
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
}
]Those are idle figures on a box serving nothing. About 550 MB is left for application workers, and that is before the first request arrives.
Now divide, and divide pessimistically. A request costs memory while it runs: a queryset that loads a few thousand rows, then a template render. Peak per worker is commonly near double the idle figure, so budget on double. Django with the admin at 58 MB idle gives you four workers on this box. Flask with SQLAlchemy at 38 MB gives you seven.
Gunicorn's (2 x cores) + 1 suggestion assumes CPU is the scarce resource and RAM is not. On a small VPS that is backwards. One shared vCPU also gives you less than one core's worth of work when the host is busy, which is worth understanding before you blame your code: CPU steal time from a noisy neighbour shows up in top as the st figure.
If your views mostly wait on a database or an upstream API, threads beat processes here. --worker-class gthread --workers 2 --threads 4 gives eight concurrent requests for the memory cost of two workers, because threads share one loaded copy of the interpreter and the framework. The global interpreter lock means threads do not help a view that burns CPU.
Give the box swap. A 1 GB VPS with no swap turns a memory spike into a killed process, while a swapfile turns the same spike into a 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 --systemThen cap the application itself. MemoryMax=600M on the gunicorn unit means the kernel takes the memory back from your app's cgroup instead of picking a victim across the whole box, so a runaway request does not cost you your SSH session.
Cold start and restart behaviour
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 is paid twice: on every deploy, and on every automatic restart after a crash. A minimal Flask app is ready in about 90 ms, and Django with the admin enabled takes about 720 ms on the same shared vCPU. Both are typical published figures. Measure your own, because your dependencies dominate.
cd /srv/site1
DJANGO_SETTINGS_MODULE=site1.settings /srv/site1/.venv/bin/python -X importtime -c "import django; django.setup()" 2>&1 | tail -20The 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 pays that cost once and every forked worker starts instantly. With preload off, each worker pays it, and gunicorn's timeout covers the boot as well as the request. A worker that has not checked in within timeout seconds is killed and replaced, so a heavy app on a slow shared vCPU can sit in a restart loop that never serves anything. The log says:
[2026-08-09 09:14:02 +0000] [981] [CRITICAL] WORKER TIMEOUT (pid:1004)Migrations belong in the unit, not in application startup code. ExecStartPre runs once before any worker exists. Putting migrate inside your app means three workers race each other against the same schema lock.
The deployment shape is nearly the same
Process manager
Both frameworks run under gunicorn, and gunicorn runs under systemd.
[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.targetRuntimeDirectory=site1 creates /run/site1 on start and removes it on stop, so the socket path always exists with the right owner. The umask = 0o007 line in the gunicorn config is what makes that socket writable by the www-data group, which is how nginx reaches it.
The Flask unit is the same file with one line changed: ExecStart=... gunicorn -c /srv/site1/gunicorn.conf.py app:app, and no ExecStartPre. The app:app argument is module then callable, so the error Failed to find attribute 'app' in 'app'. means your module does not define a variable by that name. Scheduled work fits the same pattern, and a systemd timer replaces cron for a Django management command without adding a task queue to a box this size.
Static files
Django with DEBUG = False serves no static files at all. Set STATIC_ROOT, run python manage.py collectstatic, and point the web server at the output directory. Skip that step and the admin loads with no styling while the log fills with Not Found: /static/admin/css/base.css.
There are two reasonable ways to serve them. An nginx alias block costs your app nothing. WhiteNoise, added as a middleware, serves files from the worker and saves you the nginx block, at the cost of a little worker time per file. Flask serves its own static/ folder in development, and in production you point the proxy at 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, Django needs to be told the original request was HTTPS or its cross site request forgery (CSRF) checks 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 runs containers, Traefik in front of several Docker Compose apps does the same job with labels on the container instead of one file per site.
Which database
SQLite is genuinely fine for a single application server with a write rate measured in a few per second, and it removes a whole daemon from the 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 those two options you meet django.db.utils.OperationalError: database is locked the first time two workers write at once, because the default journal mode blocks readers during a write and the default timeout gives up almost immediately. The longer argument, including where SQLite stops being the right answer, is in running SQLite in production on a VPS.
PostgreSQL on the same 1 GB box costs the 120 MB in the budget above, plus one backend process for every persistent connection. Django's CONN_MAX_AGE holds a connection open per worker, so four workers mean four backends. That is usually a good trade. Just count it before you set the worker number. If you would rather keep the database in a container beside the app, running Docker on a VPS is the same trade at a higher floor, since the daemon and each container add overhead that matters at this size.
What the batteries buy, and what they cost
Django's extra megabytes are a list of things that already exist and already 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 is the piece people underrate. It is a working database editor for your models, with search and filters, for one line in INSTALLED_APPS.
Flask is the mirror image. You get routing, a request object, Jinja2 templates and a config object. Everything else is a choice you make, which is real value when the app is small, because no ORM loads if you never import one.
The trap is 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 have assembled something with Django's memory profile and none of its coherence. Each piece has its own release cycle and its own opinion about how the app should be wired. That is the point where Django is the cheaper answer, in RAM and in the hours you spend on upgrades.
Django vs Flask: the decision rule
Use Django when the app has accounts, editable content, a schema that will keep changing, and a back office someone will actually open. Use Flask when the app is a JSON interface over a datastore that already exists, or a webhook receiver with no HTML in it.
The tiebreaker is a written list. Write down every package you would install in Flask to reach the feature set you need. If that list contains an ORM and a migration tool, you have chosen Django already and are paying extra to arrive there slowly.
One case genuinely favours Flask on small hardware: several small services on one box. Each Flask service is its own cheap process under its own unit. Three Django sites on one 1 GB VPS means three copies of the framework resident at once, and the arithmetic above stops working. If it keeps coming out short, a bigger plan is often the honest fix, and what a VPS actually costs per month is a shorter conversation than rewriting a working application.
Failure modes with the strings you will see
Workers vanish and come back. Gunicorn prints [ERROR] Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. It prints this both when it kills a worker that missed its timeout heartbeat and when the kernel killed the process. Tell the two apart with dmesg -T | grep -i "killed process". A line there means memory, so cut the worker count or add swap.
Every page returns 400 and the log says Invalid HTTP_HOST header. The full message is Invalid HTTP_HOST header: 'example.com'. You may need to add 'example.com' to ALLOWED_HOSTS. Django rejects the request before it reaches your code, because ALLOWED_HOSTS is empty or does not include the name the proxy passed in Host.
Forms fail with Origin checking failed. The page says CSRF verification failed. This happens behind a TLS terminating proxy: the app sees plain HTTP, builds an http:// origin, and compares it against a request that arrived over https://. Set SECURE_PROXY_SSL_HEADER and CSRF_TRUSTED_ORIGINS, and confirm the proxy really sends X-Forwarded-Proto.
nginx returns 502 immediately. The error log names the reason: connect() to unix:/run/site1/gunicorn.sock failed (2: No such file or directory) means the unit is not running, and (13: Permission denied) means the socket exists but nginx cannot open it, which is the umask and group setting.
The admin has no styling. collectstatic has not run, or the alias path does not match STATIC_ROOT. The access log shows 404s under /static/admin/.
Writes fail under light load. database is locked from SQLite means WAL is off or the busy timeout is too short for two workers writing at the same moment.
FAQ
Is Django too heavy for a 1 GB VPS?
No. Django with a small number of workers, nginx in front and SQLite behind runs comfortably on 1 GB. It gets tight when you add PostgreSQL at its default settings, a cache, a background worker and Docker to the same box. Measure the proportional set size of one worker, double it to allow for request peaks, and check the total against what is left after the operating system and the database.
How many gunicorn workers should I run on one vCPU?
Start at three and measure. Memory is normally the binding constraint on a small VPS, so divide the RAM left after the operating system and the database by twice one worker's proportional set size. If your views mostly wait on a database or an upstream API, switch to the gthread worker class with a small number of workers and several threads each, since threads share one loaded copy of the framework and cost far less memory than extra processes.
Do I need PostgreSQL, or is SQLite fine?
SQLite is fine for one application server with a modest write rate, and it takes a whole daemon out of the memory budget. Enable write ahead logging and set a busy timeout, or concurrent writes fail with database is locked. Move to PostgreSQL when more than one machine has to write, or when you need something SQLite does not offer such as concurrent heavy writers or per-role access control.
Should I run uvicorn instead of gunicorn?
Only if you have async views and something real to wait on. Flask is a WSGI application, so an async view runs in a fresh event loop inside the worker thread and finishes before the next request starts, which buys no extra concurrency. Django async views need an ASGI server to help at all. Recent uvicorn releases moved their gunicorn worker class into a separate package, so read the current uvicorn documentation rather than copying an old worker class flag from an older tutorial.
Why did my worker disappear with no traceback?
A process killed by the kernel out of memory killer receives SIGKILL and cannot log anything on the way out, so your application log simply stops. Gunicorn notices the gap and prints Worker (pid:1234) was sent SIGKILL! Perhaps out of memory?. Confirm it with dmesg -T | grep -i "killed process". The fix is fewer workers, or a swapfile so a memory spike becomes a slow request instead of a dead process.