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.

One request, two validators: a POST /tasks with a title of three spaces passes Pydantic's min_length=1 inside the FastAPI container, is rejected by the Back4app backend's beforeSave hook with code 142, and comes back to the client as a 422

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, Next.js SSR, an always-on worker.

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 — 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 needWhy it is not optionalWhere it lives in this post
uvicorn running your appThe obvious partContainer, section 03
HTTPS on a public URLClients and browsers refuse plain HTTPContainer, automatic
A database you don’t runData outlives the containerBackend, section 04
Validation outside the containerThe container is disposable; the rules are notBackend, section 05
Redeploy on change, without downtimeOtherwise you are back to SSHSection 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.

The container is the only piece you build an image for. The database is a URL and 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

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

# 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:

# 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):

# 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()
Measured · fastapi-tasks · September 2026
80lines in the container
→
9lines of Cloud Code
0database drivers, pools or migrations

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:

# Stack: Python 3.12 | httpx 0.28 | File: main.py (the write)
r = await backend.post("/classes/Task", json={"title": "Write the tutorial", "done": False})
r.status_code            # 201
r.json()                 # {"objectId": "OFTBmFAMs3", "createdAt": "2026-09-15T15:01:53.9Z"}

Same write, same backend. The container adds nothing the curl does not send.

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:

# 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:

// 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

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.

Measured · first deploy · September 15, 2026
86 sDeploy click → DEPLOYMENT READY
→
46 sof that, pip install + image build
17 sfirst 200 arrived before the Ready line

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

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

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

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

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: 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

Measured · Shared plan · September 16, 2026
52 splan change → redeployed, same URL
→
$5per month, 0.5 vCPU · 512 MB
60 minfree-plan lifetime per deploy

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.
  • 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); a session token in the request header scopes every query once the class is locked (the twelve-request probe).
  • 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 includes deploy-check.sh, which runs health, create, read, update, validate and delete against any deployment URL you give it.

Tagspythonfastapidockercontainersrestparse-server

Frequently asked questions

Can I deploy a FastAPI app with Docker without managing a server?

Yes. On Back4app Containers you connect a GitHub repository that has a Dockerfile; the platform builds the image with kaniko, runs uvicorn, terminates HTTPS and gives you a URL. Our first deploy went from the Deploy click to DEPLOYMENT READY in 86 seconds (September 15, 2026), 46 of them the pip install.

Where does the database come from if the container is stateless?

From a Back4app backend app: a managed Parse Server with a REST API. The FastAPI container calls it with httpx using two headers, the App ID and the REST key, and never opens a database connection. The Task class was created by the first POST; no schema was defined beforehand.

Does FastAPI need a Dockerfile on this platform?

No. The deploy form detected the repository as Python and offered a Python buildpack as the alternative to the Dockerfile. We used the Dockerfile because it pins the interpreter (python:3.12-slim) and the exact uvicorn command; the buildpack is the route when you do not want to own that.

Why did a blank title get through Pydantic but not the backend?

Field(min_length=1) counts characters, and three spaces are three characters, so Pydantic accepted ' '. The backend's beforeSave hook trims the title and rejects an empty one with error code 142, which the container maps to HTTP 422. Keep the rule in the backend so a curl command and the Python app get the same answer.

Compare with

How to Deploy a Node.js App With Docker and a Database, Without Managing a Server [Full Repo] →

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Sep 16, 2026 — 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.

More from Back4app Engineering

Containers · tutorial

How to Deploy a Node.js App With Docker and a Database, Without Managing a Server [Full Repo]

Containers · tutorial

How to Run a Node.js Bot, Webhook or Cron Job 24/7 Without a VPS [Full Repo]

Backend · tutorial

How to Add Authentication to a Web or Flutter App Without Writing an Auth Server [Full Repo]