An app that runs on your laptop with flask run or uvicorn main:app --reload is closer to production than people fear. Bind to 0.0.0.0 instead of localhost, take the port from the environment, replace the development server with gunicorn or uvicorn, pick a worker count your memory can actually pay for, and tell the app it is sitting behind a proxy. Those five things are the whole job. Everything below is the detail behind them: the numbers to size workers against, the settings that change behaviour, and the errors you will meet in roughly the order you will meet them.
The two frameworks differ in one way that decides almost every later choice. Flask is synchronous and speaks WSGI. FastAPI is asynchronous and speaks ASGI. Get that right and the rest follows.
ASGI or WSGI: what your framework needs#
WSGI is the old, synchronous Python web interface: the server hands your application a request and waits for a response. One worker handles one request at a time. It is simple, it is battle-tested, and it cannot hold a websocket open.
ASGI is the asynchronous replacement. A single process can hold thousands of connections at once, as long as the code running inside it gives up control while it waits for input and output. That is what makes websockets, server-sent events and long-polling possible in one process.
| Framework | Interface | Server to use | Import string |
|---|---|---|---|
| Flask | WSGI | gunicorn | wsgi:app |
| Django | WSGI (ASGI optional) | gunicorn | myproject.wsgi:application |
| FastAPI | ASGI | uvicorn | app.main:app |
| Starlette | ASGI | uvicorn | app.main:app |
Flask has supported async def view functions since 2.0, and it is easy to read too much into that. Flask runs an async view by starting an event loop for that one request and waiting for it to finish, so the worker is still occupied for the whole request. You get the syntax, not the concurrency. If you need one process to serve many slow requests at once, you need an ASGI framework, not an async view in a WSGI one.
The reverse case is worth stating too. If your app is a handful of CRUD endpoints in front of a database, Flask on gunicorn with a few threads will serve it perfectly well, and it is less machinery to get wrong. Pick FastAPI for the type validation and the generated OpenAPI schema, not because async is faster on paper.
Requirements, the virtualenv and the install step#
Everything the server runs comes from a pinned requirements file installed into a virtual environment that belongs to the app. Not the system Python, not whatever pip resolved on the day.
fastapi==0.115.0uvicorn[standard]==0.30.6gunicorn==22.0.0pydantic-settings==2.4.0psycopg[binary]==3.2.1$ python -m venv .venv$ .venv/bin/pip install --upgrade pip$ .venv/bin/pip install --no-cache-dir -r requirements.txtCalling .venv/bin/pip and .venv/bin/python directly, rather than activating the environment, removes a whole category of "it works in my shell" problems - there is no shell state to get wrong. --no-cache-dir matters on a small plan: pip's wheel cache lives in the home directory and quietly grows to hundreds of megabytes on a disk that might only be 5 GB. Python requirements and virtualenvs goes through pinning, lock files and the packages that try to compile themselves on a half-core machine.
The [standard] extra on uvicorn is worth having. It pulls in uvloop and httptools, which replace the pure-Python event loop and HTTP parser with C implementations, plus the websockets library. It is the single cheapest throughput improvement available to a FastAPI app, and it costs one line.
Where the install runs is a decision. Putting pip install -r requirements.txt in front of your start command means every restart reinstalls, which is slow but guarantees the running code and the declared dependencies agree. Installing once by hand is faster to boot but drifts the first time somebody adds a package and forgets. On a host that pulls your repository on start, the first option is usually right: pip skips anything already satisfied, so a restart with no dependency changes adds a couple of seconds, not a couple of minutes.
The start command: binding, ports and the one thing that breaks#
This is where most first deployments fail, and it is always the same thing. Inside a container, 127.0.0.1 means the container itself. Nothing outside can reach it. The server has to bind 0.0.0.0.
# FastAPI, single process$ uvicorn app.main:app --host 0.0.0.0 --port 8000# Flask, four gunicorn workers$ gunicorn wsgi:app --bind 0.0.0.0:8000 --workers 4# FastAPI under gunicorn, which supervises uvicorn workers$ gunicorn app.main:app -k uvicorn.workers.UvicornWorker \ --bind 0.0.0.0:8000 --workers 4That third form is the traditional way to run several uvicorn workers, and it is in the middle of a migration. From uvicorn 0.30 the built-in uvicorn.workers module is deprecated in favour of a separate uvicorn-worker package, whose class is uvicorn_worker.UvicornWorker. Check which version you pinned before copying the line. Uvicorn can also run its own workers with --workers 4 and no gunicorn at all, which is one less dependency for the same result.
The import string is module:attribute, where the module path uses dots and the attribute is the application object. app.main:app means "the variable app inside app/main.py". If your Flask app uses the factory pattern, gunicorn will call it for you: gunicorn "myapp:create_app()". Uvicorn wants --factory instead.
Take the port from the environment rather than hard-coding it, because the port a panel or platform allocates is not always 8000:
$ uvicorn app.main:app --host 0.0.0.0 --port ${SERVER_PORT:-8000}Panels built on Pterodactyl expose the allocated port as an environment variable on the Startup tab; other platforms use PORT. Look at the tab, use the name you find, and keep a sane default for your own machine.
Workers, threads and what each one costs in memory#
The gunicorn documentation suggests (2 x cores) + 1 workers. That advice assumes a machine whose cores are yours. On a container with a hard CPU share of half a core, it produces a number that will exhaust your memory long before it helps anybody, because every worker is a separate operating system process holding its own copy of the interpreter and every module you imported.
Measure it once, on your own app, with ps -o rss= -p PID after it has served some traffic. As a starting point: a small Flask app sits around 50-80 MB per worker; a FastAPI app with pydantic models and a database driver is nearer 80-150 MB; anything importing numpy, pandas or a machine-learning library is in a different league and should be sized by measurement only.
| Plan memory | CPU share | Flask (sync workers) | FastAPI (async workers) |
|---|---|---|---|
| 1 GB | 0.5 core | 1-2 | 1 |
| 2 GB | 1 core | 2-3 | 1-2 |
| 4 GB | 1.5 cores | 3-4 | 2 |
| 6-8 GB | 2-3 cores | 4-6 | 2-3 |
Those columns assume roughly 150 MB a worker and leave headroom for the request bodies, caches and the occasional large response. An async worker appears low because it does not need friends: one uvicorn process can serve hundreds of concurrent requests when the work is waiting on a database. More workers past the CPU share you bought buys nothing.
Two settings change the shape of this:
--worker-class gthread --threads 4gives a sync gunicorn worker four threads. Four requests that are all waiting on a database can be in flight at once, for the memory cost of one process. For an I/O-bound Flask app this is the best value setting there is.--max-requests 1000 --max-requests-jitter 100retires a worker after about a thousand requests and starts a fresh one. It is a blunt instrument, and it is also how you survive a slow leak in a dependency you do not control. The jitter stops every worker recycling at the same moment.
For FastAPI there is one rule that outranks all of this: never block the event loop. A synchronous database call, a requests.get, or a CPU-heavy loop inside an async def handler stops every other request in that process until it finishes. FastAPI protects you partly by running plain def handlers in a threadpool with a few dozen slots, so a synchronous endpoint is safe; an async def endpoint containing blocking code is not.
Over-provisioning workers has a specific failure mode on container hosts. On RE:NODE, a server that reaches its memory limit is stopped by the kernel and restarted clean rather than left to swap, so one worker too many shows up as an app that dies under load instead of an app that slows down. The crash watcher notices repeated restarts and opens a ticket after three in an hour, which is a useful signal that your worker count and your plan disagree.
Behind a reverse proxy: HTTPS, client IPs and timeouts#
In production something terminates TLS in front of your app and forwards plain HTTP to it. That something sets three headers - X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host - and your app ignores all of them until you tell it not to.
Uvicorn reads the forwarded headers by default in current versions, but only from addresses listed in --forwarded-allow-ips, which defaults to 127.0.0.1. If your proxy is on another address the headers are discarded, and every client looks like the proxy. Set it to the proxy's address, or to * when nothing but the proxy can reach the port.
Flask needs a middleware:
from flask import Flaskfrom werkzeug.middleware.proxy_fix import ProxyFixapp = Flask(__name__)app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)The numbers say how many proxies deep to trust. One proxy means 1. Setting them higher than the number of proxies you actually run lets a client forge its own address by sending the header itself, which is the standard way people accidentally defeat their own rate limiting. What a reverse proxy does covers the header chain in more detail.
Two more proxy-shaped problems. Redirect loops happen when the app believes the request was HTTP, redirects to HTTPS, and the proxy forwards the same request again - fixing the proto header fixes the loop. And websockets need the upgrade headers passed through plus a read timeout long enough for an idle connection, which is a separate subject in websockets behind a reverse proxy.
App plans on RE:NODE include a proxy slot: point an A record at the address shown and the certificate is issued and renewed automatically inside a 21-day window, with the real client address arriving in X-Forwarded-For. That means the configuration above is not optional there - it is the only way your logs show anything but one repeated address.
Environment variables and settings#
Configuration belongs in the environment, not in the repository. The pattern that survives contact with a team is a typed settings object that fails loudly at start if something is missing:
from pydantic_settings import BaseSettings, SettingsConfigDictclass Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env") database_url: str secret_key: str debug: bool = Falsesettings = Settings()A missing DATABASE_URL now stops the process at import with a clear message, instead of producing a connection error on the first request an hour later. Flask has a smaller version of the same idea: app.config.from_prefixed_env() loads every variable beginning with FLASK_ into the config object.
DATABASE_URL=postgresql://app:secret@db.example.net:5432/appSECRET_KEY=change-meDEBUG=falsePYTHONUNBUFFERED=1Keep .env out of git and set the real values on the server. Anything typed into a panel's Startup tab is visible to everyone who can open that server, so give collaborators a role that excludes it if they only need the console - environment variables and secrets works through the rest, including what to do after a key has been pasted somewhere it should not have been.
Static files, uploads and the database#
Flask serves its own static/ folder. FastAPI does it with a mount:
from fastapi.staticfiles import StaticFilesapp.mount("/static", StaticFiles(directory="static"), name="static")Serving assets from Python is fine at small scale. It stops being fine when a worker is blocked reading a 40 MB file for someone on a slow connection, which is a real cost on a one or two worker plan. If you have a proxy in front, let it cache what it can, and send long Cache-Control values on anything with a hash in its filename.
User uploads must not go to /tmp, and they must not go anywhere that gets replaced on deploy. Pick a directory on the persistent disk, write there, and remember it counts against the plan's disk - 5 GB on the smallest app tier, 50 GB at the top. Uploads are also the thing people forget to back up, because they are not in the repository and not in the database.
For the database, the number to watch is connections, not queries. Each gunicorn worker creates its own connection pool, so the total is workers multiplied by pool size:
engine = create_engine( settings.database_url, pool_size=5, max_overflow=5, pool_pre_ping=True,)Four workers with that configuration can open forty connections under load, which is enough to exhaust a small PostgreSQL server on its own. Size the pool down as you size workers up, and read connection pools and limits before assuming the default is safe. Panel database slots come with a generated host, user and password; a separate PostgreSQL or MongoDB instance is a database hosting plan.
Run migrations before the server starts, not inside it:
$ .venv/bin/alembic upgrade head && .venv/bin/uvicorn app.main:app \ --host 0.0.0.0 --port ${SERVER_PORT:-8000}Logs, health checks and shutting down cleanly#
Python buffers stdout when it is not attached to a terminal. On a panel console this looks like an app that prints nothing for ten minutes and then dumps everything at once, and it is the most common false alarm in application hosting. Set PYTHONUNBUFFERED=1 and it goes away.
Gunicorn writes error logs to stderr by default but writes no access log at all until you ask: --access-logfile - sends it to stdout. Uvicorn logs access lines by default and --no-access-log turns them off, which is worth doing if a health check is polling you every five seconds and burying everything else.
A health endpoint should be cheap and should not touch the database:
@app.get("/healthz")async def healthz(): return {"status": "ok"}The reason is that a liveness check which queries the database restarts your app when the database has a bad minute, turning a small outage into a crash loop. If you want a check that covers dependencies, make it a second endpoint and treat its result as information, not as grounds for a restart.
Both servers handle SIGTERM properly: they stop accepting new connections, let in-flight requests finish, and exit. Gunicorn gives workers --graceful-timeout seconds (30 by default) before killing them. FastAPI's lifespan shutdown runs at that point, which is where you close pools and flush anything buffered. Graceful shutdown and health checks has the full pattern, including why a request that takes longer than the graceful timeout is a design problem rather than a configuration one.
What goes wrong#
The log says "Uvicorn running" but nothing connects. The host is 127.0.0.1. Change it to 0.0.0.0 and restart.
"Address already in use" on start. A previous process is still holding the port. Use the panel's Kill, or find it with ss -ltnp on a machine where you have a shell.
`ModuleNotFoundError` after a deploy that worked locally. A package you installed by hand months ago never made it into requirements.txt. Recreate your virtualenv from the file locally and watch it fail the same way.
Everything is fine until four people use it at once. One sync worker with no threads. Add --threads 4, then add workers if memory allows.
Requests die at exactly thirty seconds. Gunicorn's --timeout, which kills a worker that has not finished. Raise it if the endpoint is legitimately slow, but a thirty-second web request usually wants to be a background job instead.
Too many redirects. The app thinks the request came in over HTTP. Fix X-Forwarded-Proto handling before touching the redirect code.
The client IP is the same for every request. Forwarded headers are not being trusted. Set --forwarded-allow-ips or add ProxyFix.
Memory grows all day and the server restarts overnight. Something is leaking. --max-requests buys time while you find it; the console's memory graph tells you whether it is a slope or a step.
FAQ#
Do I need gunicorn if I already use uvicorn?
No. Uvicorn can manage its own workers with --workers, and for most deployments that is enough. Gunicorn is worth adding when you want its process supervision, its access log format, or settings like --max-requests that uvicorn does not have.
How many workers fit in 1 GB?
One or two. A FastAPI app is typically 80-150 MB per worker before it does any work, and the container has to hold request bodies and any cache as well. On the smallest plans, one worker with threads or async concurrency beats two workers competing for the same memory.
Is Flask's async support the same as FastAPI's?
No. Flask runs an async view inside a fresh event loop and still occupies the worker for the whole request, so you get the syntax without the concurrency. FastAPI runs on an event loop that serves every connection in the process at once.
Can I run a background worker on the same server?
Yes, if it is small: start it from the same command with & so both processes live in the same container. It shares the plan's CPU and memory with the web process, so anything heavy deserves its own server. For periodic work rather than a queue, a scheduler running inside the application is usually the cheaper answer.
Do I need nginx in front of uvicorn?
Not if something else already terminates TLS and forwards to you. Uvicorn is a competent HTTP server. What a proxy adds is certificates, static file caching, request size limits and a place to rate limit - if you already have one in front, a second one adds a hop and nothing else.
Why does the panel console show no output until the app crashes?
Python is buffering stdout because it is not a terminal. Set PYTHONUNBUFFERED=1 in the environment, or pass -u to the interpreter, and output appears as it is written.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.