all posts

Deploying FastAPI without writing a Dockerfile

Ajay Kumar··8 min read

Python deploys go wrong in a small number of very specific ways, and if you've deployed a few you can probably recite them: the interpreter is the wrong version, `uvicorn` isn't on PATH, the server binds to localhost and nothing can reach it, or a native dependency needs a compiler that isn't installed. Four failures, three of which have nothing to do with your application code.

This is how each one happens and how to avoid it, using a git-driven deploy with no Dockerfile. I build PandaStack, so the platform specifics are ours — the four failure modes are universal and the fixes mostly are too.

Why no Dockerfile

A Dockerfile for a FastAPI app is thirty lines you copy from a blog post and never fully read, and it encodes decisions — base image, Python version, whether to use a virtualenv inside a container, how to install build dependencies — that you didn't want to make. It's fine, but it's ceremony.

The alternative is a platform that reads what your repo already declares. A `requirements.txt` or `pyproject.toml` says what to install. A `.python-version` says which interpreter. A `Procfile` or config field says how to start. That's the same information the Dockerfile carried, minus the transcription step.

On PandaStack there's one universal base image — Ubuntu 24.04 with mise managing runtimes — and your version files are resolved at deploy time. It also includes a C toolchain, which matters more than it sounds like, as failure three explains.

Failure 1: the wrong Python version

You develop on 3.12 and the platform's default is 3.11, and something in your dependency tree cares. The failure is usually an import error deep in a library, or a syntax feature that doesn't parse.

The fix is to declare it in the file that already exists for this purpose. `.python-version` with `3.12` is enough. If you're using mise directly, `mise.toml` or `.tool-versions` work too, and can pin multiple runtimes if your project also needs Node for a frontend build.

# .python-version -- one line, read by pyenv, mise, and most platforms
3.12

# or .tool-versions, if you need more than one runtime
python 3.12.7
nodejs 22.11.0
Pin the minor version at least. An unpinned 'python 3' will drift to whatever the platform ships this quarter, and you will find out during an unrelated deploy on a Friday.

Failure 2: uvicorn: command not found

This one is specific to platforms using a version manager with shims, and it's the single most common Python deploy failure I see. The install succeeds. The start command fails with `uvicorn: not found`. The package is definitely installed.

What's happening: mise (like pyenv and asdf) puts shims on your PATH pointing at the active runtime's executables. When pip installs a package with a console script, that script lands in the runtime's own `bin` directory — not in the shim directory. Until a reshim runs, the shim directory has no entry for it, so the shell can't find it even though the file exists on disk.

The deploy pipeline runs `mise reshim` after installing dependencies for exactly this reason. If you're hitting it on your own infrastructure, either reshim after install or invoke through the interpreter, which sidesteps PATH entirely.

# Fragile: depends on a console script being visible on PATH
uvicorn app.main:app --host 0.0.0.0 --port $PORT

# Robust: runs the module through the interpreter, no shim needed
python -m uvicorn app.main:app --host 0.0.0.0 --port $PORT

# Same idea for gunicorn with uvicorn workers (real production choice)
python -m gunicorn app.main:app \
  --worker-class uvicorn.workers.UvicornWorker \
  --workers 2 --bind 0.0.0.0:$PORT --timeout 60

The `python -m` form is worth adopting as a habit regardless of platform. It's immune to PATH ordering, to shims, and to the classic case where two virtualenvs are active and you're running the wrong project's binary.

Failure 3: the native dependency

`psycopg2`, `pillow`, `lxml`, `cryptography`, `numpy` from source — anything that compiles. The install dies partway through with a wall of compiler output, and the actual error is fifty lines above where you stopped reading. Usually it's a missing header package or no compiler at all.

Two mitigations. First, prefer wheels: `psycopg2-binary` instead of `psycopg2`, or a package with prebuilt wheels for your platform and Python version. Wheels are why most Python deploys don't need a compiler, and a missing wheel for a new Python release is a common cause of 'it broke when I upgraded'.

Second, use a runtime that has a toolchain when compilation is unavoidable. Our base template includes one, which is also what makes Go builds and cgo-dependent packages work. If your platform's build environment is minimal, this is where a Dockerfile genuinely earns its place — sometimes you do need to install system packages, and then you should.

Failure 4: bound to localhost

The deploy succeeds. The health check fails, or the app returns a connection error through the proxy. Inside the VM, `curl localhost:8000` works perfectly. This is the one that eats afternoons.

`uvicorn` defaults to `127.0.0.1`, which accepts connections only from inside the machine. The proxy and the health check reach your process over the guest's network interface, so a localhost-only bind answers nobody. Always `--host 0.0.0.0`.

A health check that probes localhost inside the guest instead of the network address will pass for a localhost-bound app that serves no external traffic — a green check on a dead app. We had that bug and fixed it by probing the address the proxy actually dials. If you build health checking yourself, probe the same address your traffic uses.

Bind to the port the platform gives you, too. Read `$PORT` rather than hardcoding 8000; platforms assign it and will route to what they assigned, not what you preferred.

Putting it together

A FastAPI repo that deploys cleanly needs four things committed, and none of them is a Dockerfile.

// pandastack.json -- explicit beats auto-detection
{
  "framework": "python",
  "install": "pip install -r requirements.txt",
  "start": "python -m gunicorn app.main:app --worker-class uvicorn.workers.UvicornWorker --workers 2 --bind 0.0.0.0:$PORT",
  "port": 8000
}
  • `.python-version` — pins the interpreter.
  • `requirements.txt` or `pyproject.toml` — pins dependencies. Pin them properly; an unpinned transitive dependency is a deploy that behaves differently on Tuesday.
  • A start command using `python -m`, binding `0.0.0.0` and `$PORT`.
  • A health endpoint that returns 200 without touching the database — otherwise a database blip makes the platform believe your app is dead and restart it, which does not help.

That last point deserves emphasis. Health checks should answer 'is this process able to serve?' not 'is the entire system healthy?'. If your health endpoint queries Postgres, then a slow query turns into a failed health check, which turns into a restart, which drops in-flight requests and adds a cold start to a system already under stress. Keep readiness and liveness distinct if the platform supports it, and keep the liveness check trivial.

One note on workers

For anything beyond a toy, run gunicorn with uvicorn workers rather than bare uvicorn — you get worker supervision and restarts for free. Worker count should be based on the memory available, not the classic CPU formula, because each worker is a full interpreter with your imports loaded, and on a fixed-memory machine four workers of a heavy app will OOM where two would be comfortable.

That's especially true on snapshot-restore platforms, where RAM is a property of the baked template rather than a per-app slider. Ours is 4 GiB. Start with two workers, watch memory under real load, and increase only if there's headroom. An app killed for exceeding memory looks exactly like a crash with no error message, which is a bad afternoon regardless of how good your logging is.

Frequently asked questions

Can I deploy a FastAPI app without writing a Dockerfile?

Yes. A Dockerfile mostly transcribes information your repository already declares: a .python-version file states the interpreter, requirements.txt or pyproject.toml states the dependencies, and a start command states how to run the server. A platform that reads those files directly can build and run the app without a container image. The case where a Dockerfile still earns its place is when you need specific system packages installed, since that is genuinely outside what a Python manifest can express.

Why does my deploy say uvicorn: command not found when it is installed?

Because of how version-manager shims work. Tools like mise, pyenv, and asdf put shim executables on PATH that point at the active runtime. When pip installs a package with a console script such as uvicorn or gunicorn, that script lands in the runtime's own bin directory, not in the shim directory, so PATH lookup fails even though the file exists. The fixes are to run a reshim after installing dependencies, or — more robustly — to invoke through the interpreter with python -m uvicorn, which bypasses PATH resolution entirely and is immune to shim and virtualenv confusion.

Why does my app work inside the VM but not through the proxy?

It is almost certainly bound to 127.0.0.1. Uvicorn defaults to localhost, which accepts connections only from inside the machine, so curl works from a shell in the guest while the reverse proxy and the health checker — which reach the process over the guest's network interface — get nothing. Always pass --host 0.0.0.0, and bind the port the platform assigns via $PORT rather than hardcoding 8000. A related platform-side bug is a health check that probes localhost inside the guest, which will report a localhost-bound app as healthy while it serves no external traffic at all.

How many gunicorn workers should I run?

Base it on available memory rather than the traditional CPU-count formula. Each worker is a full Python interpreter with your application's imports loaded, and a heavy app can be hundreds of megabytes per worker, so on a machine with fixed memory four workers may exceed the limit where two are comfortable. This matters especially on snapshot-restore platforms where RAM is a property of the baked template rather than a per-app setting. Start with two, observe memory under realistic load, and add workers only with clear headroom — a worker killed for exceeding memory looks like a crash with no error message.

What should a health check endpoint do?

As little as possible. Its job is to answer whether this process can serve requests, not whether the whole system is healthy. If the endpoint queries the database, then a slow or briefly unavailable database causes health check failures, which cause restarts, which drop in-flight requests and add cold starts to a system already under stress — converting a small dependency blip into an outage. Keep a liveness check trivial and, if your platform distinguishes them, put dependency checks in a separate readiness endpoint whose failure removes the instance from rotation rather than restarting it.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.