How to deploy a Flask app without Docker
Flask is small enough that it's often a team's first deployment, and that means hitting several production questions at once with no framework opinion to lean on. Django would have told you what to do. Flask assumes you know.
Here's the short version of what you need to know, in the order it matters. No Dockerfile required — a repo with a requirements file and a WSGI entry point is enough for any platform with Python build detection. Commands are PandaStack's.
1. Stop using the development server
`flask run` is single-threaded by default, has no process supervision, and its debugger is a remote code execution vector if it ever reaches the internet. The warning it prints is not a formality.
In production you run a real WSGI server — Gunicorn is the default choice — pointed at your application object.
# A module-level app object
gunicorn "app:app" --workers 3 --timeout 60 --bind 0.0.0.0:$PORT
# An application factory — note the call syntax
gunicorn "app:create_app()" --workers 3 --bind 0.0.0.0:$PORT
# Async views or a lot of I/O waiting? Threads are cheaper than processes
gunicorn "app:create_app()" --workers 2 --threads 4 --bind 0.0.0.0:$PORT2. Use an application factory
A module-level `app = Flask(__name__)` works and gets awkward fast: configuration is bound at import time, tests share one instance, and extensions get initialised in whatever order the imports happen to run.
A factory function makes configuration explicit and testable, and Gunicorn calls it directly.
import os
from flask import Flask
def create_app(config=None):
app = Flask(__name__)
# Fail loudly at startup if a required secret is missing, rather than
# silently running with a default that makes sessions forgeable
app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ["DATABASE_URL"]
if config:
app.config.update(config)
from .routes import bp
app.register_blueprint(bp)
return app3. Understand what multiple workers break
This is the part that catches people, because everything works locally with one process and fails intermittently in production with three.
- Module-level dictionaries used as caches. Each worker has its own, so a value written by one is invisible to the others. Symptom: a cache that works about a third of the time.
- In-memory rate limiters. Three workers means three times your intended limit. Use Redis.
- Filesystem-backed sessions. Each worker writes to its own view of disk on an ephemeral filesystem. Use signed cookies or a shared store.
- Background threads started at import. Each worker starts its own, so a periodic job runs three times as often. This is how duplicate emails happen.
- SQLAlchemy engines created before fork. Connections get shared across processes and corrupt in ways that are extremely confusing to debug. Create the engine inside the factory, or dispose the pool in a post-fork hook.
4. Configuration from the environment
Read secrets from environment variables, and read them without a default. A SECRET_KEY that falls back to a hard-coded string means anyone who has read your repository can forge sessions, and the app will never tell you it's happening.
# Pin the interpreter, then deploy from git
echo "3.12" > .python-version
pandastack apps create --name api \
--git-url https://github.com/acme/flask-api \
--build-cmd 'pip install -r requirements.txt' \
--start-cmd 'gunicorn "app:create_app()" --workers 3 --bind 0.0.0.0:$PORT'
# Secrets as environment variables — never in the repository
pandastack apps env set api SECRET_KEY="$(openssl rand -hex 32)"5. Database connections
Your total connection count is pool size × workers × instances. Three workers with a pool of five across two instances is thirty connections, which is fine — until you scale to eight instances and discover the database's limit is a hundred and something else needed connections too.
Set the pool size explicitly rather than accepting the default, and make sure the session is removed at the end of each request so connections return to the pool.
6. Log to stdout, and give yourself a request id
Flask's default logging is aimed at development. In production your platform collects stdout and stderr, so anything written to a file inside the instance is invisible and disappears at the next deploy. Configure logging to stdout explicitly and at INFO rather than the default WARNING, or you will eventually debug an incident with nothing to look at.
Emitting JSON rather than free text is worth it if your platform indexes logs, because structured fields let you filter by user or route during an incident instead of scrolling. More useful still is a request id: generate one per request, attach it to every log line, and return it in a response header. When a customer reports an error at 14:32, that identifier is the difference between finding the request in ten seconds and never finding it.
Before real traffic
- Confirm the debugger is off. Flask's debug mode reachable from the internet is remote code execution, not a rough edge.
- Run with the production worker count and hit an endpoint that uses any module-level state. If behaviour varies between requests, you found a per-worker state bug.
- Multiply pool size by workers by instances and compare to the database's limit.
- Deploy while sending traffic and watch for 502s during the flip — that's shutdown handling.
- Check that no secret has a default value in code. Grep for os.environ.get with a fallback.
The short version
Gunicorn instead of the development server, an application factory, secrets from the environment with no defaults, and an honest audit of anything stored at module level — because that's what multiple workers turn from a convenience into an intermittent bug. None of it needs a Dockerfile, and all of it takes about an hour the first time.
Frequently asked questions
Can I deploy Flask without Docker?
Yes. A repository with a requirements file and a WSGI entry point gives any platform with Python build detection everything it needs — it installs dependencies and runs the start command you provide, typically Gunicorn pointed at your application object or factory. Pin the interpreter with a .python-version file so the build resolves the same wheels your lockfile assumed. Reach for a Dockerfile when you need system packages the build image lacks, such as image codecs or a specific database client library, or when you want an identical image in CI and production. For a standard Flask API, it adds a build step and nothing else.
Why can't I use flask run in production?
Three reasons, and the third is serious. It is single-threaded by default, so one slow request blocks every other one. It has no process supervision, so a crash is simply the end of your service. And its interactive debugger, if it is ever enabled on a reachable host, allows arbitrary code execution through the browser — it is protected by a PIN, which is not a security boundary you should rely on. A production WSGI server such as Gunicorn or uWSGI handles multiple workers, restarts them when they die, applies request timeouts, and has no debugger to leave on by accident.
Why does my Flask app behave differently with multiple workers?
Because each Gunicorn worker is a separate process with its own memory, so anything stored at module level exists once per worker rather than once per application. A dictionary used as a cache is populated in one worker and missing in the others, giving you a cache that appears to work roughly one time in three. An in-memory rate limiter allows your limit multiplied by the worker count. A background thread started at import runs once per worker, so a periodic job fires three times as often. Move shared state to Redis or the database, and start scheduled work in exactly one place.
How do I handle configuration and secrets in Flask?
Read them from environment variables inside an application factory, and read required values without a default. Using os.environ['SECRET_KEY'] means a missing secret fails loudly at startup; using os.environ.get with a hard-coded fallback means the application runs happily with a key that anyone who has read your repository knows, and sessions become forgeable with no visible symptom. Keep the factory as the single place configuration is assembled so tests can build an app with different settings, and make sure secrets are delivered as platform-managed environment variables rather than committed files.
How many Gunicorn workers should a Flask app use?
Base it on memory and on what your app spends its time doing. Measure the resident memory of one worker under real load, divide your instance memory by that, and keep headroom — for a small Flask API this often lands at three or four workers on a 1 GB instance. If your requests are mostly waiting on I/O rather than computing, adding threads per worker is cheaper than adding processes, since threads share memory. Whatever you choose, set a request timeout so a single slow endpoint cannot occupy a worker indefinitely, and verify the total database connection count that configuration implies.
Keep reading
49ms p50 cold start. Fork, snapshot, and scale to zero.