# How to Deploy FastAPI With Docker and a Database, Without Managing a Server [Full Repo]

A FastAPI task API with a Dockerfile, live and saving data in 86 seconds, no server or database to run. Measured step by step, including the validation bug Pydantic let through and the backend caught.

URL: https://www.back4app.com/blog/deploy-python-fastapi-dockerfile-database
Published: 2026-09-25 · Tested: 2026-09-16
Tested on: Containers: Back4app, Shared plan, 0.5 vCPU, 512 MB (free plan for the first hour) · Backend: Back4app, USA East · Local: Python 3.12, FastAPI 0.115, httpx
Publisher: Back4app Engineering

Your FastAPI app runs on `localhost:8000`. It has a Dockerfile. And the moment you look for somewhere to put it, the question changes from "where does my code run?" to "where does my data live, and who is patching that Postgres?"

This post is about a Python API: FastAPI validating the shape of each request, a backend storing the rows, and the rule about what a valid task is living in exactly one place. A **container platform** runs uvicorn. A **backend** gives you the database and an API you did not write. The second one is where the servers hide.

The other three shapes have their own posts: [Node.js with Docker](https://www.back4app.com/blog/deploy-node-app-dockerfile-back4app), [Next.js SSR](https://www.back4app.com/blog/deploy-nextjs-app-github-backend), [an always-on worker](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).

We built a task API — 80 lines of FastAPI, a seven-line Dockerfile — pushed it, and had it storing data behind HTTPS without a server to administer. The first deploy took **86 seconds** from click to `DEPLOYMENT READY`. Everything below is from that run, on September 15–16, 2026, including the part Pydantic got wrong.

**Get the working code:** the companion repo is at [github.com/templates-back4app/fastapi-tasks](https://github.com/templates-back4app/fastapi-tasks) — `main.py`, `Dockerfile`, `requirements.txt`, `cloud/main.js`, and `deploy-check.sh`, which runs create, read, update, validate and delete against any deployment URL.

## What does "without managing a server" include for a Python API?

Five things, and a Dockerfile only covers the first one. A platform can honestly claim the phrase only if it covers the whole list:

| What you need | Why it is not optional | Where it lives in this post |
|---|---|---|
| uvicorn running your app | The obvious part | Container, section 03 |
| HTTPS on a public URL | Clients and browsers refuse plain HTTP | Container, automatic |
| A database you don't run | Data outlives the container | Backend, section 04 |
| Validation outside the container | The container is disposable; the rules are not | Backend, section 05 |
| Redeploy on change, without downtime | Otherwise you are back to SSH | Section 06 |

Most container hosts stop at the first two: they run the Dockerfile and give you a URL. The database is a separate, separately billed service with a connection string, and the pool, the migrations and the backups are yours.

Back4app is the one we tested because the second half is a product from the same account: a **backend app** with a managed database and a REST API, which a **container app** calls over HTTPS. No driver, no pool, no connection string — two headers.

## What does the FastAPI task API contain?

A task API — the smallest app that exercises both halves. Six routes: `POST /tasks`, `GET /tasks` (with `?done=true`), `GET`, `PATCH` and `DELETE` on `/tasks/{id}`, and `GET /healthz`. FastAPI validates the shape, the backend stores the data and owns the rules.

![Architecture: clients call the FastAPI container; the container turns each request into a REST call to the Back4app backend, where a Cloud Code hook validates every Task before it reaches the managed database](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/architecture-diagram.svg)

The whole Dockerfile. `EXPOSE 8080` is the line the platform reads for the port:

```dockerfile
# Stack: Docker | Python 3.12 (slim) | File: Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]
```

The container reads its configuration from the environment and talks to the backend through one shared client:

```python
# Stack: Python 3.12 | FastAPI 0.115 + httpx | File: main.py (setup)
APP_ID = os.environ["PARSE_APP_ID"]
REST_KEY = os.environ["PARSE_REST_KEY"]
BASE = os.environ.get("PARSE_SERVER_URL", "https://parseapi.back4app.com")
HEADERS = {"X-Parse-Application-Id": APP_ID, "X-Parse-REST-API-Key": REST_KEY, "Content-Type": "application/json"}

app = FastAPI(title="tasks")
backend = httpx.AsyncClient(base_url=BASE, headers=HEADERS, timeout=10)

class TaskIn(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    done: bool = False
```

A route is a Pydantic model in, one REST call, and a status code the client understands. The backend answers with Parse error codes; `parse_error` maps them (`101` → 404, `142` → 422, `209` → 401, anything else → 502):

```python
# Stack: Python 3.12 | FastAPI 0.115 + httpx | File: main.py (create + read)
@app.post("/tasks", status_code=201)
async def create_task(task: TaskIn):
    r = await backend.post("/classes/Task", json=task.model_dump())
    if r.status_code != 201:
        parse_error(r)
    return {**task.model_dump(), **r.json()}

@app.get("/tasks/{task_id}")
async def get_task(task_id: str):
    r = await backend.get(f"/classes/Task/{task_id}")
    if r.status_code != 200:
        parse_error(r)
    return r.json()
```

You need a free Back4app account, a GitHub account and `curl`. No Docker on your machine: the platform builds the image, and this post was produced on a Mac without Docker installed. Python only for running the app locally first.

## Where does the database come from?

From a backend app you create in the dashboard — **New App → Build your Backend**, name it, **Create**. The Overview page shows the App ID and, under the Keys dropdown, the REST API key. Those two values are the entire database configuration of the container.

You do not define the `Task` class. The first `POST /classes/Task` created it, with `title` as a String and `done` as a Boolean inferred from the JSON (Parse Server 7.5.2, September 2026). Here is the same write two ways — the one the container makes, and the one you can make from your terminal with the same keys:

Filtering is a `where` parameter with JSON in it. `GET /tasks?done=true` in the container becomes `GET /classes/Task?where={"done":true}&order=-createdAt` on the backend — no query builder, no ORM, and the same URL works in a browser with the two headers:

```bash
# Stack: bash | Endpoint: GET /classes/Task with a where filter
curl -G "$BASE/classes/Task" \
  -H "X-Parse-Application-Id: $APP_ID" \
  -H "X-Parse-REST-API-Key: $REST_KEY" \
  --data-urlencode 'where={"done": true}' \
  --data-urlencode 'order=-createdAt'
```

## Why does the validation rule live in the backend, not in Pydantic?

The rules about your data. Pydantic checks the *shape* of a request; it does not know what a valid task is, and it runs only in this one container. A rule in the backend applies to the Python app, to a curl command, and to the next container you write in another language.

`cloud/main.js` runs *inside the backend*. It trims the title, rejects an empty one, and defaults `done`:

```javascript
// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
Parse.Cloud.beforeSave("Task", (request) => {
  const t = request.object;
  const title = t.get("title");
  if (typeof title !== "string" || !title.trim())
    throw new Parse.Error(Parse.Error.VALIDATION_ERROR, "title is required.");
  t.set("title", title.trim());
  if (t.get("done") === undefined) t.set("done", false);
});
```

Paste it into **Cloud Code → cloud/main.js** in the backend dashboard, **Deploy**, and prove it with a request. Ours answered `400 {"code":142,"error":"title is required."}` to a title of three spaces — after the second deploy.

## How do you deploy a FastAPI Dockerfile from GitHub?

Push the repo to GitHub, then **New App → Deploy from GitHub** on the Containers side. The first time, GitHub asks you to install the Back4app Containers app on the repositories you choose; the list in the dashboard updates the moment you finish.

**Configure.** Select the repo. The form detected the Dockerfile and offered a **Python buildpack** as the alternative — it read the language from the repository. We kept the Dockerfile, left the port on auto, and added `PARSE_APP_ID` and `PARSE_REST_KEY`.

We also changed **Health check** from `/` to `/healthz`. It turned out not to matter for the deploy — see the errors section — but it is the route your own monitoring should use.

![The deploy form for fastapi-tasks: Dockerfile selected with a Python Buildpack alternative, two environment variables with masked values, health check /healthz](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/02-deploy-form-v2.jpg)

**Deploy.** We clicked at 15:00:16. The button read "Deploying…" for 30 seconds before the app page appeared; then `PREPARING` at 15:00:46, `BUILDING IMAGE` for 46 seconds (`pip install` on `python:3.12-slim`), `LAUNCHING CONTAINER` at 15:01:33, and `DEPLOYMENT READY` at 15:01:42.

How we measured: a stopwatch from the Deploy click to the dashboard's `DEPLOYMENT READY` line, free plan, one deploy on September 15, 2026, with a loop polling the public URL every second in parallel to catch the first `200`. `pip install` dominates, so your time moves with `requirements.txt`.

`deploy-check.sh` then did the whole round trip against the live URL: health, create (`201`, `objectId` back), read, update (`PATCH {"done": true}`), validate (a blank title → `422`), delete (`204`). Twelve seconds after Ready, all six passed.

FastAPI's own `/docs` page works unchanged on the URL, which is the fastest way to show someone the API without sending them a curl:

![The FastAPI Swagger UI at /docs on the b4a.run URL listing the six routes and the TaskIn, TaskPatch and validation schemas](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/05-swagger-docs-v2.jpg)

## Where do the tasks end up? The backend behind the API

Three screens in the backend dashboard, and every row on them was written by the container above — no seed data, no schema defined by hand.

**Overview — where the two headers come from.** Dashboard → New App → Build your Backend → Create. The Overview page shows the App ID and, under the Keys dropdown, the REST API key. Those two values become `PARSE_APP_ID` and `PARSE_REST_KEY` in the container form; there is no connection string anywhere.

![The backend Overview for fastapi-tasks: App ID, the Keys dropdown set to Rest Key (value blurred), Parse Server 7.5.2 and MongoDB 3.6 on the right](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/06-backend-overview-v2.jpg)

**Cloud Code — where the rule lives.** `cloud/main.js` open in the editor with the `beforeSave("Task")` hook, and the badge *Files pending deploy (1)* next to the Deploy button. On a fresh backend that badge is the tell: it stays after the first Deploy, which is how you know nothing shipped.

![The Cloud Code editor with cloud/main.js open showing the beforeSave Task hook, and the badge Files pending deploy (1) next to the Deploy button](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/08-cloud-code-pending.jpg)

**Database Browser — the Task class.** Six rows. The three newest came through the container's `POST /tasks` while we wrote this section; the one titled *Deploy the container* was sent with spaces around it and stored trimmed. The two rows with no visible title are three spaces each — written on September 15, before the hook was deployed, which is exactly the hole the hook closes.

![The Database Browser showing the Task class with six objects: title and done columns inferred by the first POST, three new tasks from 17 Sept, and two older rows whose title is blank](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/07-database-task.jpg)

## How long does the URL last, and what does permanent cost?

On the free plan, 60 minutes from the moment the deploy starts — then the container is destroyed and the dashboard shows **URL Expired**. On the Shared plan, $5/month for 0.5 vCPU and 512 MB, the URL is permanent and the plan change alone redeployed the app on the same address (all as of September 16, 2026).

The free hour is real and it is enough: every step in this post, including the mistakes, fits in one window. It is not enough to leave the API running for a client, and the platform says as much — the free card reads "Ideal for developing, learning, and prototyping".

We measured the expiry to the minute on the worker from [our always-on post](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps): deploy started 14:55:28, URL gone at 15:55:27. This API showed the same **URL Expired** status the next morning.

The next morning we changed the plan. Without a click on Deploy, the log showed `PREPARING DEPLOYMENT` at 12:35:32 and `DEPLOYMENT READY` at 12:36:24 — 52 seconds — on the same `b4a.run` URL, and `deploy-check.sh` passed again.

![The container Overview on the Shared plan: $5.00 per month, 0.5 vCPU and 512 MB, status Available, the automatic redeploy Ready in 52 s, the log ending in the uvicorn CMD and Pushed image](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/04-shared-plan-ready.jpg)

## What do 422, 404 and 502 mean in this API, and how do you fix them?

We broke the deployment on purpose, and the platform broke it once for us. Each entry starts with what you will actually see.

**`KeyError: 'PARSE_APP_ID'` the moment uvicorn imports the app.** `os.environ["PARSE_APP_ID"]` raises at import time when the variable is missing, so there is no process for the health check to reach. Add the two variables under Settings → Environment and deploy again; a saved variable takes effect on the next deploy, not the running one.

**`422 {"detail":{"code":142,"error":"title is required."}}` on a title that Pydantic accepted.** Working as intended: the backend hook rejected it. The shape of the `422` differs from Pydantic's list of errors — if your client parses `detail`, handle both a list and an object.

**`404 {"detail":{"code":101,"error":"Object not found."}}` on `GET /tasks/{id}`.** The id is not a Task's `objectId`; Parse answers `101` and the container maps it to 404. Ids are case-sensitive ten-character strings like `OFTBmFAMs3`.

**`502 {"detail":{"error":"{\"error\":\"unauthorized\"}"}}` on every route except `/healthz`.** A wrong `PARSE_REST_KEY`. The backend answers `unauthorized` with no Parse code, so `parse_error` falls through to 502 with the raw body as a string. The health check passed because `/healthz` does not touch the backend — and the platform's check is a port check anyway. Make `/healthz` run a cheap query so `deploy-check.sh` and your own monitor catch a bad key.

**`DEPLOYMENT READY` on an API whose routes all fail.** The platform's health step checks that the port answers, not the status: on our worker a path that returned `404` passed it. A wrong key or a crashed handler is therefore not caught at deploy time — run `deploy-check.sh` against the URL after every deploy.

**`404 not found` on the URL, dashboard says `URL Expired`.** The free hour is over and the container is gone. Redeploy for another hour, or change the plan — the plan change redeploys on its own.

## When is this the wrong answer?

Per our own manifesto, a post where only the platform wins does not get published — so here is the other column.

- **Your API is CRUD and nothing else.** Then you do not need the container: the backend's REST layer *is* the API, and a Python client can call it directly with the same two headers.
- **You need Postgres features.** Joins across many tables, window functions, PostGIS, a schema you migrate with Alembic — the backend is Parse Server on MongoDB (Postgres on paid plans), and its query language is `where` JSON, not SQL.
- **You need a background worker next to the API.** One container is one process; a queue consumer belongs in its own container, as in [our always-on post](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).
- **You want to build images locally and push them.** The image route accepts Docker Hub only.

If none of those describe you, a stateless FastAPI container in front of a managed backend is the shape we would choose again — and the Pydantic-plus-hook pair is how we would validate it.

## What would you add to the task API next?

You have an API that redeploys in under a minute, a database you never provisioned, and rules that survive the container being replaced. From here:

- **Give tasks owners.** Parse's `User` class adds signup and login endpoints with no container code ([measured, with password reset](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); a session token in the request header scopes every query once the class is locked ([the twelve-request probe](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to)).
- **Turn on autodeploy.** On a paid plan, Settings → Build & deploy → Autodeploy; we measured a push to live in 44 seconds on the worker.
- **Make the health check honest.** Have `/healthz` run `GET /classes/Task?limit=0` against the backend so a bad key shows up in your monitoring, not at the first user.

All code above is complete and tested — the [companion repo](https://github.com/templates-back4app/fastapi-tasks) includes `deploy-check.sh`, which runs health, create, read, update, validate and delete against any deployment URL you give it.
