On a small server the right number of gunicorn workers is usually one or two, not the (2 x cores) + 1 from the documentation, and the settings that matter after that are the ones nobody sets: a timeout that matches your slowest honest request, max_requests with jitter to retire leaky workers, preload_app only if your code survives a fork, and a keepalive that suits the proxy in front of you. Uvicorn needs fewer settings but has the same shape: --workers for processes, --timeout-keep-alive, --limit-max-requests. This post is about configuring and operating them. How much memory each worker costs is a separate question, covered in how much RAM a Python app needs.
Everything here was written for a container with a fixed memory limit and a hard CPU share - the situation on a $4 to $7 Python plan - but it applies to any small VPS. If you are still working out the basics of getting an app online, start with deploy FastAPI or Flask or deploy Django to production and come back.
What gunicorn and uvicorn actually run#
Gunicorn is a process manager that happens to speak HTTP. There is one master process that never touches a request. It listens on the socket, forks workers, watches them, and replaces any that die or stall. Each worker is a full Python interpreter with its own copy of your application, its own imported modules, its own database connections and its own caches. Nothing is shared between workers except the listening socket, which is why a counter in a module-level variable counts per worker, and why an in-memory cache is really N separate caches.
Uvicorn is a server for ASGI applications (FastAPI, Starlette, Django with asgi.py). A single uvicorn process runs one event loop and serves many requests concurrently, as long as your code awaits instead of blocking. With --workers N it becomes a small supervisor like gunicorn's master: it spawns N processes, each with its own loop, and respawns any that exit.
Two consequences shape every decision below. First, each worker costs a full copy of your app in memory, so on a plan with a memory limit the worker count is a budget, not a tuning knob. Second, concurrency inside a worker depends on the worker class: a sync worker handles exactly one request at a time, a threaded worker handles one per thread, and an async worker handles as many as its event loop can interleave.
The default gunicorn command binds 127.0.0.1:8000 with one sync worker. Inside a container that address is unreachable from outside, so the first change on any host is --bind 0.0.0.0:<port>, with the port taken from the environment. On a RE:NODE Python server the allocated port is shown on the Startup tab; the sibling posts use SERVER_PORT for it, so check the name on your own tab before copying a line.
Choosing the worker count#
The documentation's (2 x cores) + 1 assumes cores that are yours and memory you have not counted. Neither holds on a small plan. A CPU share is a hard throttle: a plan with half a core gives the whole process tree half a core of time, however many workers you start. Extra workers do not make the app faster; they split the same half core more ways and multiply the memory bill.
Work from the two limits in turn.
- CPU. For a CPU-bound app, workers beyond
ceil(cpu share)buy nothing. For an app that mostly waits on a database or an upstream API, the number of workers matters less than the number of requests in flight per worker, which is what threads and async are for. - Memory. Measure the resident size of one worker after it has served real traffic, multiply by the worker count, add the master (a few tens of MB) and keep at least a quarter of the limit free for request bursts and the interpreter's own growth.
$ ps -o pid,rss,cmd -C gunicorn PID RSS CMD 4121 31204 gunicorn: master [app.main:app] 4128 96740 gunicorn: worker [app.main:app] 4129 97112 gunicorn: worker [app.main:app]RSS is in kilobytes. Run it after a few hundred requests, not at startup - a worker that has not yet imported lazily loaded modules or warmed its caches looks smaller than it will be an hour later.
A starting point for the Python line, assuming roughly 100 MB per worker, which is typical for a FastAPI or Flask app with an ORM and a database driver (yours may be double that):
| Plan | Memory / CPU share | Starting config |
|---|---|---|
| Starter | 1 GB / 0.5 core | 1 worker, 4 threads or async |
| Standard | 2 GB / 1 core | 2 workers, 2-4 threads each |
| Advanced | 4 GB / 1.5 cores | 2-3 workers |
| Pro | 6 GB / 2 cores | 3-4 workers |
The ladder is on the Python plans, from $4 a month. Notice that memory is rarely the first limit on the larger rows - the CPU share is. Past two or three workers on a single-digit share of cores you are mostly buying context switches.
There is one more reason to prefer two workers over one when you can afford it: a single worker is a single point of stall. If it hits a slow request, a garbage collection pause or a restart, the service is unreachable for that whole moment. Two workers mean the other one keeps answering while the first recovers. On a 1 GB plan that trade is often not available, which is exactly when threads or async earn their place.
Worker classes: sync, gthread and uvicorn#
Gunicorn's worker_class decides how a worker handles requests, and it is the biggest lever after the count.
| Worker class | Concurrency per worker | Use for |
|---|---|---|
sync (default) | 1 request | Flask or Django, CPU-bound, plenty of workers |
gthread | threads requests | Flask or Django waiting on a database |
uvicorn_worker.UvicornWorker | Event loop | FastAPI, Starlette, Django ASGI |
`sync` is simple and predictable, and it is also the one that most needs a buffering proxy in front: a sync worker is occupied for as long as a client takes to send its request, so a slow connection pins a whole worker. Behind a proxy that reads the full request before forwarding it, this is not a problem.
`gthread` gives each worker a pool of threads. Four threads in one process serve four requests that are all blocked on a database for roughly the memory of one process, which is the best value setting for an I/O-bound WSGI app on a small plan:
$ gunicorn wsgi:app --bind 0.0.0.0:8000 \ --workers 2 --worker-class gthread --threads 4Mind the arithmetic downstream: two workers with four threads each can hold eight database connections at once. Size your pool and your database plan to that, not to the worker count. Setting --threads above 1 with the default sync class switches the class to gthread for you in current releases, but writing it out removes the guesswork.
Uvicorn workers under gunicorn give you gunicorn's supervision with uvicorn's event loop:
$ gunicorn app.main:app -k uvicorn_worker.UvicornWorker \ --bind 0.0.0.0:8000 --workers 2This needs the uvicorn-worker package. The older uvicorn.workers.UvicornWorker that shipped inside uvicorn is deprecated and may be gone in the version you pin, so check pip show uvicorn before copying a line from an old tutorial. Alternatively skip gunicorn:
$ uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_PORT:-8000} --workers 2Which to pick? Uvicorn's own --workers is one dependency fewer and perfectly adequate for a small app. Gunicorn adds a mature set of operational features: max_requests with jitter, per-worker timeouts that kill a stuck worker, post_fork hooks, a long-established signal protocol and finer log control. Uvicorn has been closing that gap in recent releases (see the reload section), but if you need to be sure a hung worker is killed, gunicorn is the safer supervisor today.
One rule for async workers: never block the event loop. A synchronous database call or requests.get inside an async def handler stops every other request in that process until it returns. A blocked loop does not raise an error. It shows up as latency, and under gunicorn eventually as a WORKER TIMEOUT, because the loop also drives the worker's heartbeat to the master.
Timeouts that kill requests#
Gunicorn's timeout (default 30 seconds) is widely misunderstood. It is not a request timeout. It is the longest a worker may go without telling the master it is alive. For sync workers that heartbeat happens between requests, so it behaves like a per-request limit: a request that takes 31 seconds gets its worker killed. The log line is unmistakable:
[2026-10-17 09:14:02 +0000] [4121] [CRITICAL] WORKER TIMEOUT (pid:4128)[2026-10-17 09:14:03 +0000] [4128] [INFO] Worker exiting (pid: 4128)[2026-10-17 09:14:03 +0000] [4140] [INFO] Booting worker with pid: 4140For gthread and async workers the heartbeat comes from a different place, so a slow request does not by itself trip the timeout; a blocked main thread or event loop does. That difference matters when you debug: a WORKER TIMEOUT on an async worker means something froze the loop, not that one request ran long.
These are the timeouts in play and who owns them:
| Setting | Default | What it limits |
|---|---|---|
gunicorn timeout | 30 | Silence from a worker before it is killed |
gunicorn graceful_timeout | 30 | How long a stopping worker may finish requests |
gunicorn keepalive | 2 | Idle seconds on a keep-alive connection |
uvicorn --timeout-keep-alive | 5 | Idle seconds on a keep-alive connection |
uvicorn --timeout-graceful-shutdown | none | Wait for connections on shutdown |
Raising timeout to 120 because one report endpoint is slow is the common wrong answer. It also lets every genuinely stuck worker sit idle for two minutes, on a plan where there are only two of them. The better options, in order:
- Make the slow thing fast: an index, a smaller query, a cache.
- Move it out of the request: return
202 Acceptedand do the work in a background job, then poll for the result. - If it truly must be synchronous, raise
timeoutand say why in a comment next to it, and keep threads or an extra worker so that one long request does not take the whole app with it.
Uvicorn has no request timeout at all. If an upstream call hangs, the handler hangs, so set timeouts on every outbound client (httpx.AsyncClient(timeout=10), a database statement_timeout) rather than expecting the server to rescue you. The proxy in front also has its own read timeout, usually longer than gunicorn's default; if the proxy gives up first, the client sees a 504 while your worker is still busy for another few seconds.
Finally, worker_tmp_dir. Gunicorn writes heartbeat files to a temporary directory, and in containers whose temp directory is on a slow or overlay filesystem those writes can block and cause spurious timeouts. The documented fix is a memory-backed directory:
$ gunicorn app.main:app --worker-tmp-dir /dev/shmPreload, hooks and the fork trap#
By default each worker imports your application itself, after the fork. With --preload (or preload_app = True) the master imports it once, before forking. Two things follow.
What you gain. Start-up errors surface in the master straight away, instead of every worker failing to boot in a loop. Workers also start faster, and the pages holding imported code can be shared copy-on-write between processes, so total memory is lower than N independent imports.
What you should not count on. Python's reference counting writes to objects as they are used, which dirties pages and un-shares them over time. The saving is real but partial: expect it to shrink as workers age. Measure resident memory with and without --preload before deciding it is worth the complication.
What you pay. Anything created at import time is created once and then inherited by every worker. That is harmless for configuration and disastrous for connections:
- A database engine or connection that was opened at import is now shared by several processes using the same underlying socket. Queries interleave, and you get errors that appear only under load and only some of the time.
- Threads started at import exist only in the master. The workers never get them.
- Random seeds and other per-process state are copied, so workers behave identically where you expected them not to.
The fix is to create connections after the fork, in a hook. Gunicorn's config file is plain Python, so hooks are functions:
def post_fork(server, worker): # runs in each worker after the fork, before it serves anything from app.db import engine engine.dispose(close=False)engine.dispose(close=False) is the SQLAlchemy 2.x way of discarding the pool inherited from the master without closing connections the master still owns. For any other client library, the pattern is the same: throw away what the master created and let each worker open its own. If your app opens connections lazily on first use, as most ORMs do, preload is usually safe as it stands.
Preload has one more interaction that bites people during deploys, covered in the next sections: with --preload, a HUP signal re-reads the config but does not re-import your code, because the code was loaded in the master and the master did not restart.
Keepalive and the proxy in front#
HTTP keep-alive lets a client reuse one TCP connection for many requests. For a small app the connection that matters is between the proxy and your server. On RE:NODE app and web plans include a reverse-proxy slot that terminates TLS and forwards plain HTTP to your port, passing the real client address in X-Forwarded-For. See what a reverse proxy does for the general picture.
The two settings:
- Gunicorn `keepalive`, default 2 seconds, applies only to the
gthreadand async worker classes. Asyncworker closes the connection after every response, because it cannot afford to sit waiting on an idle connection while it is the only thing serving traffic. - Uvicorn `--timeout-keep-alive`, default 5 seconds.
The classic bug is a race. The proxy keeps an idle connection open to your app and sends a request on it at the same instant your server decides the connection has been idle long enough and closes it. The proxy sees a reset and returns a 502 for a request that never reached your code. It is intermittent, it rises with low traffic rather than high, and nothing appears in your app log. The rule is that the app's keep-alive timeout must be longer than the proxy's idle timeout towards the app. Check how long your proxy keeps upstream connections idle; the proxy slot's value is not something this post can tell you, so measure instead. If you see occasional 502s with a clean app log, raise keepalive to 10 or so as an experiment and watch whether they stop.
Setting it very high has a cost: every idle connection occupies a slot. In a gthread worker, a connection held open occupies a thread. With four threads and a generous keep-alive, four idle connections can leave the worker with nobody to serve a fifth client. Keep it modest, 5 to 10 seconds, and keep the thread count above the number of proxy connections you expect.
Trusting the forwarded headers is the other half. Both servers only believe X-Forwarded-For and X-Forwarded-Proto from addresses they trust: gunicorn through forwarded_allow_ips (environment FORWARDED_ALLOW_IPS), uvicorn through --forwarded-allow-ips. Both default to 127.0.0.1, which does not match a proxy that connects from another address. The result is that every request appears to come from the proxy and arrives as plain HTTP, so redirect URLs are built with http:// and your logs show one visitor. Setting the value to * fixes it, and is only acceptable if the app's port cannot be reached except through the proxy. If you are unsure whether it can, put the proxy's address in the list instead and check what your server logs as the peer address.
max-requests and slow leaks#
max_requests tells gunicorn to retire a worker after it has served that many requests and replace it with a fresh one. It is a blunt, effective tool against a slow leak in your code or a dependency you do not control, and against the gradual fragmentation that makes long-running Python processes larger over time. Always pair it with jitter:
max_requests = 1000max_requests_jitter = 100The jitter adds a random number from 0 to 100 to each worker's limit, so they do not all hit it on the same request. Without it, on a two-worker plan, both workers can recycle within moments of each other, and there is a window in which none is ready to serve. A worker being replaced finishes the request it is on first, then exits, so users do not see an error; they see, at worst, a slightly slower response while the new worker boots.
Pick a number from your own memory graph rather than from a blog. Watch the Console tab's memory graph over a day. If a worker's resident size climbs steadily with request count, set max_requests so that a worker is retired well before it reaches the point where the plan runs out of headroom. If memory is flat, you do not need it, and recycling just throws away warm caches and import work for nothing.
Uvicorn has the same thing as --limit-max-requests N: the process exits after N requests. Under gunicorn's supervision it is respawned. With uvicorn's own --workers, recent releases respawn it too, but older ones do not, which would leave your service with fewer processes after each recycle. Check the release you pinned before relying on it, and prefer gunicorn if you cannot confirm it.
max_requests hides a leak; it does not fix it. If you reach for it, write down why, and look at the cause when you have time. The memory side of this is in Node and Python memory limits for the Node equivalent, and game server memory leaks describes the discipline of telling a leak from normal growth.
Reloading without dropping requests#
Gunicorn's master responds to signals, and they are the whole operational interface:
| Signal | Effect |
|---|---|
HUP | Re-read config, start new workers, retire old ones gracefully |
TERM | Graceful shutdown, waits up to graceful_timeout |
INT, QUIT | Immediate shutdown |
TTIN, TTOU | Add or remove one worker |
USR1 | Reopen log files (after rotation) |
USR2 | Start a new master alongside the old one |
HUP is the one for deploys. New workers boot with the new code and config before the old ones are told to stop, and the old ones finish what they are doing, so in-flight requests complete. On your own machine:
$ gunicorn app.main:app -c gunicorn.conf.py --pid /run/myapp.pid$ kill -HUP $(cat /run/myapp.pid)Under systemd, which is the sensible way to run this on a VDS, expose it as a reload action:
[Service]User=appWorkingDirectory=/srv/myappExecStart=/srv/myapp/.venv/bin/gunicorn app.main:app -c gunicorn.conf.pyExecReload=/bin/kill -s HUP $MAINPIDKillMode=mixedTimeoutStopSec=40systemctl reload myapp then sends the HUP. TimeoutStopSec should exceed graceful_timeout, or systemd kills the process while workers are still draining. There are some caveats you have to know before depending on it:
- With `preload_app`, `HUP` does not pick up new code. The code lives in the master. To deploy new code under preload, restart, or use the
USR2handover (start a new master, then sendWINCHandQUITto the old one), which is intricate enough that most small deployments choose a restart. - A broken new release can take the service down. If the new code fails to import, new workers fail to boot (
Worker failed to boot) while the old ones are shut down. Runpython -c "import app.main"orgunicorn --check-configbefore the signal. - New dependencies need installing first.
HUPdoes not runpip. See Python requirements and virtualenvs. - Database schema changes are your problem. Old and new workers run side by side for a few seconds, so a migration that breaks the old code will break it.
Uvicorn grew similar behaviour in its multi-process mode: recent releases restart workers one by one on SIGHUP and add or remove workers on SIGTTIN and SIGTTOU. As with other uvicorn details, confirm it in the release notes of the version you pin.
On a panel host the picture is different. The panel's Restart button is a full stop and start of the server process, which is a clean, simple and safe way to deploy, and it will drop connections for the few seconds the app takes to boot. The panel console types into the process rather than sending it signals, so a HUP is not something to expect from it; we have not confirmed a signal option there. For a deploy with no gap on a small plan, read zero-downtime deploys on a small server, and graceful shutdown and health checks for what a stopping process owes its clients. If the Git deploy is switched on, a push restarts the server, so the same boot-time gap applies.
A complete config for a small plan#
Put the settings in gunicorn.conf.py in the application's working directory, where gunicorn looks by default, so the start command stays short and the settings are reviewed in git like everything else. This one suits a FastAPI app on a Standard plan (2 GB, 1 core):
import osbind = f"0.0.0.0:{os.environ.get('SERVER_PORT', '8000')}"workers = int(os.environ.get("WEB_CONCURRENCY", "2"))worker_class = "uvicorn_worker.UvicornWorker"timeout = 30graceful_timeout = 30keepalive = 10max_requests = 1000max_requests_jitter = 100worker_tmp_dir = "/dev/shm"forwarded_allow_ips = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1")accesslog = "-"errorlog = "-"loglevel = "info"capture_output = TrueThe start command is then just gunicorn app.main:app -c gunicorn.conf.py. Logs go to standard output and error, which is where the Console tab reads them; there is nothing to rotate. For a Flask or Django app on the same plan, swap in worker_class = "gthread", add threads = 4, and drop the uvicorn line.
Check the file before you start the server, and when it will not start:
$ gunicorn app.main:app -c gunicorn.conf.py --check-config$ gunicorn app.main:app -c gunicorn.conf.py --print-config | grep -E "^(workers|timeout)"The first catches typos in setting names and import errors; the second prints what gunicorn actually resolved, after environment variables and the command line have been applied. Command-line flags override the file, which is how you end up debugging a setting that is "clearly" in the config and not taking effect.
Troubleshooting#
`WORKER TIMEOUT` repeating. A worker went silent for longer than timeout. For sync workers, find the slow request (log request duration with %(D)s in access_log_format). For async workers, look for blocking calls inside async def. If it only happens at boot, the app is taking longer than timeout to import; preload helps, as does raising timeout for start-up only.
`Worker (pid:N) was sent SIGKILL! Perhaps out of memory?` Something killed a worker without gunicorn's involvement, and on a memory-limited plan the memory limit is the first suspect. At the limit the kernel stops processes and the container restarts clean rather than swapping. The fix is fewer workers, fewer threads or a smaller app, in that order. Repeated restarts are noticed: three in an hour puts a warning on the server page and opens a ticket, and six suspend the server.
`Worker failed to boot` and the master exits. The import failed. Run the same import by hand with the venv's interpreter and read the traceback. Without --preload this appears once per worker, which is noisy but harmless.
Occasional 502s from the proxy with a clean app log. The keep-alive race described above. Raise keepalive or --timeout-keep-alive and see.
Everything slow at 100% CPU, plenty of memory free. You are CPU-throttled. More workers make it worse. Profile the app, cache, or move to a plan with a larger CPU share. A server at 100% CPU is slow, never suspended for it.
Database errors only under load. Either connections were opened before the fork (--preload) or the product of workers, threads and pool size exceeds what the database allows. Count it: workers times threads times pool size.
Redirects go to `http://`. The server is not trusting X-Forwarded-Proto. Set forwarded_allow_ips as described above, and in Django set SECURE_PROXY_SSL_HEADER.
FAQ#
How many gunicorn workers should I run on a 1 GB plan?
One, with several threads or an async worker class, unless a measurement shows your app uses under about 200 MB per worker in total. A second worker buys resilience to a single stall, but on 1 GB with 0.5 core it often costs more in memory and CPU contention than it saves.
Is uvicorn with --workers enough, or do I need gunicorn?
For a small app, uvicorn alone is enough and has one fewer dependency. Choose gunicorn when you want the stronger supervision: a timeout that kills a hung worker, max_requests with jitter, hooks and a long-standing signal interface.
Should I turn on preload?
Only if your startup is heavy or you want import errors in the master, and only after checking that nothing opens a connection or starts a thread at import time. Measure memory with and without it; the copy-on-write saving in Python is smaller than people expect.
What is a sensible max_requests?
Whatever retires a worker before its memory reaches your headroom. A thousand with a jitter of a hundred is a common starting point. If your memory graph is flat you do not need it at all.
Why does gunicorn kill a request that runs for 31 seconds?
Because timeout defaults to 30 and a sync worker that has not answered by then is presumed stuck. Make the request faster or move it to a background job before raising the limit, and raise it only for as long as you need to.
Can I reload gunicorn without downtime on a panel host?
Not with a signal that we have confirmed. Restart is a clean stop and start with a few seconds of gap. If you need no gap, run two instances behind something that can drain one, or move to a VDS where you control systemd and can send HUP.


