# Cómo desplegar FastAPI con Docker y una base de datos sin administrar un servidor

Una API de tareas en FastAPI con un Dockerfile, en producción y guardando datos en 86 segundos, sin servidor ni base de datos que administrar. Medido paso a paso, incluido el bug de validación que Pydantic dejó pasar y el backend atrapó.

URL: https://www.back4app.com/blog/es/como-desplegar-fastapi-con-docker-y-base-de-datos
Publicado: 2026-11-01 · Probado: 2026-09-16
Probado en: Containers: Back4app, plan Shared, 0,5 vCPU, 512 MB (plan Free durante la primera hora) · Backend: Back4app, USA East · Local: Python 3.12, FastAPI 0.115, httpx
Publisher: Back4app Engineering

Tu app FastAPI corre en `localhost:8000`. Tiene un Dockerfile. Y en cuanto buscas dónde ponerla, la pregunta cambia de "¿dónde corre mi código?" a "¿dónde viven mis datos y quién parchea ese Postgres?".

Este post trata de una API en Python: FastAPI validando la forma de cada solicitud, un backend guardando las filas y la regla sobre qué es una tarea válida viviendo en un único sitio. Una **plataforma de containers** ejecuta uvicorn. Un **backend** te da la base de datos y una API que no escribiste. En el segundo es donde se esconden los servidores.

Las otras tres formas tienen sus propios posts: [Node.js con Docker](https://www.back4app.com/blog/deploy-node-app-dockerfile-back4app), [Next.js con SSR](https://www.back4app.com/blog/deploy-nextjs-app-github-backend), [un worker siempre activo](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).

Construimos una API de tareas (80 líneas de FastAPI, un Dockerfile de siete líneas), la subimos y la tuvimos guardando datos detrás de HTTPS sin un servidor que administrar. El primer deploy tardó **86 segundos** del clic a `DEPLOYMENT READY`. Todo lo que sigue sale de esa ejecución, el 15 y 16 de septiembre de 2026, incluida la parte en la que Pydantic se equivocó.

**Consigue el código funcionando:** el repositorio companion está en [github.com/templates-back4app/fastapi-tasks](https://github.com/templates-back4app/fastapi-tasks) — `main.py`, `Dockerfile`, `requirements.txt`, `cloud/main.js` y `deploy-check.sh`, que ejecuta create, read, update, validate y delete contra cualquier URL de deploy.

## ¿Qué incluye "sin administrar un servidor" para una API en Python?

Cinco cosas, y un Dockerfile solo cubre la primera. Una plataforma solo puede usar esa frase con honestidad si cubre la lista entera:

| Lo que necesitas | Por qué no es opcional | Dónde vive en este post |
|---|---|---|
| uvicorn ejecutando tu app | La parte obvia | Container, sección 03 |
| HTTPS en una URL pública | Los clientes y los navegadores rechazan HTTP plano | Container, automático |
| Una base de datos que no administras | Los datos sobreviven al container | Backend, sección 04 |
| Validación fuera del container | El container es desechable; las reglas, no | Backend, sección 05 |
| Redeploy en cada cambio, sin caída | Si no, vuelves a SSH | Sección 06 |

La mayoría de los hosts de containers se quedan en las dos primeras: ejecutan el Dockerfile y te dan una URL. La base de datos es un servicio aparte, facturado aparte, con una cadena de conexión, y el pool, las migraciones y las copias de seguridad son tuyos.

Back4app es el que probamos porque la segunda mitad es un producto de la misma cuenta: una **app de backend** con una base de datos gestionada y una REST API, a la que una **app de container** llama por HTTPS. Sin driver, sin pool, sin cadena de conexión: dos headers.

## ¿Qué contiene la API de tareas en FastAPI?

Una API de tareas: la app más pequeña que ejercita las dos mitades. Seis rutas: `POST /tasks`, `GET /tasks` (con `?done=true`), `GET`, `PATCH` y `DELETE` en `/tasks/{id}`, y `GET /healthz`. FastAPI valida la forma, el backend guarda los datos y es dueño de las reglas.

![Arquitectura: los clientes llaman al container de FastAPI; el container convierte cada solicitud en una llamada REST al backend de Back4app, donde un hook de Cloud Code valida cada Task antes de que llegue a la base de datos gestionada](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/architecture-diagram.svg)

El Dockerfile completo. `EXPOSE 8080` es la línea que la plataforma lee para el puerto:

```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"]
```

El container lee su configuración del entorno y habla con el backend a través de un único cliente compartido:

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

Una ruta es un modelo de Pydantic de entrada, una llamada REST y un código de estado que el cliente entiende. El backend responde con códigos de error de Parse; `parse_error` los mapea (`101` → 404, `142` → 422, `209` → 401, cualquier otro → 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()
```

Necesitas una cuenta gratuita de Back4app, una cuenta de GitHub y `curl`. Docker en tu máquina, no: la plataforma construye la imagen, y este post se produjo en un Mac sin Docker instalado. Python solo para ejecutar la app en local primero.

## ¿De dónde sale la base de datos?

De una app de backend que creas en la dashboard: **New App → Build your Backend**, ponle nombre, **Create**. La página Overview muestra el App ID y, en el desplegable Keys, la REST API key. Esos dos valores son toda la configuración de base de datos del container.

La clase `Task` no la defines tú. La creó el primer `POST /classes/Task`, con `title` como String y `done` como Boolean, inferidos del JSON (Parse Server 7.5.2, septiembre de 2026). Aquí está la misma escritura de dos formas: la que hace el container y la que puedes hacer desde tu terminal con las mismas claves:

Filtrar es un parámetro `where` con JSON dentro. `GET /tasks?done=true` en el container se convierte en `GET /classes/Task?where={"done":true}&order=-createdAt` en el backend: sin query builder, sin ORM, y la misma URL funciona en un navegador con los dos 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'
```

## ¿Por qué la regla de validación vive en el backend y no en Pydantic?

Porque son las reglas sobre tus datos. Pydantic comprueba la *forma* de una solicitud; no sabe qué es una tarea válida, y solo se ejecuta en este container. Una regla en el backend se aplica a la app en Python, a un comando curl y al próximo container que escribas en otro lenguaje.

`cloud/main.js` se ejecuta *dentro del backend*. Recorta el título, rechaza uno vacío y pone `done` por defecto:

```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);
});
```

Pégalo en **Cloud Code → cloud/main.js** en la dashboard del backend, **Deploy**, y demuéstralo con una solicitud. El nuestro respondió `400 {"code":142,"error":"title is required."}` a un título de tres espacios, después del segundo deploy.

## ¿Cómo se despliega un Dockerfile de FastAPI desde GitHub?

Sube el repositorio a GitHub y después **New App → Deploy from GitHub** en el lado de Containers. La primera vez, GitHub te pide instalar la app de Back4app Containers en los repositorios que elijas; la lista de la dashboard se actualiza en cuanto terminas.

**Configura.** Selecciona el repositorio. El formulario detectó el Dockerfile y ofreció un **buildpack de Python** como alternativa: leyó el lenguaje del repositorio. Nos quedamos con el Dockerfile, dejamos el puerto en automático y añadimos `PARSE_APP_ID` y `PARSE_REST_KEY`.

También cambiamos **Health check** de `/` a `/healthz`. Resultó no importar para el deploy (mira la sección de errores), pero es la ruta que tu propia monitorización debería usar.

![El formulario de deploy de fastapi-tasks: Dockerfile seleccionado con un Python Buildpack como alternativa, dos variables de entorno con los valores ocultos, health check /healthz](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/02-deploy-form-v2.jpg)

**Despliega.** Hicimos clic a las 15:00:16. El botón decía "Deploying…" durante 30 segundos antes de que apareciera la página de la app; después `PREPARING` a las 15:00:46, `BUILDING IMAGE` durante 46 segundos (`pip install` sobre `python:3.12-slim`), `LAUNCHING CONTAINER` a las 15:01:33 y `DEPLOYMENT READY` a las 15:01:42.

Cómo lo medimos: un cronómetro desde el clic en Deploy hasta la línea `DEPLOYMENT READY` de la dashboard, plan Free, un deploy el 15 de septiembre de 2026, con un bucle consultando la URL pública cada segundo en paralelo para capturar el primer `200`. `pip install` domina, así que tu tiempo se mueve con `requirements.txt`.

`deploy-check.sh` hizo después el ciclo completo contra la URL en producción: health, create (`201`, con el `objectId` de vuelta), read, update (`PATCH {"done": true}`), validate (un título en blanco → `422`), delete (`204`). Doce segundos después de Ready, las seis pasaron.

La página `/docs` de FastAPI funciona sin cambios en la URL, que es la forma más rápida de enseñarle la API a alguien sin mandarle un curl:

![La Swagger UI de FastAPI en /docs sobre la URL b4a.run, con las seis rutas y los schemas TaskIn, TaskPatch y de validación](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/05-swagger-docs-v2.jpg)

## ¿Dónde acaban las tareas? El backend detrás de la API

Tres pantallas de la dashboard del backend, y cada fila que aparece en ellas la escribió el container de arriba: sin datos de semilla, sin schema definido a mano.

**Overview: de dónde salen los dos headers.** Dashboard → New App → Build your Backend → Create. La página Overview muestra el App ID y, en el desplegable Keys, la REST API key. Esos dos valores se convierten en `PARSE_APP_ID` y `PARSE_REST_KEY` en el formulario del container; no hay cadena de conexión en ningún sitio.

![La Overview del backend de fastapi-tasks: App ID, el desplegable Keys en Rest Key (valor difuminado), Parse Server 7.5.2 y MongoDB 3.6 a la derecha](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/06-backend-overview-v2.jpg)

**Cloud Code: donde vive la regla.** `cloud/main.js` abierto en el editor con el hook `beforeSave("Task")`, y la insignia *Files pending deploy (1)* junto al botón Deploy. En un backend recién creado esa insignia es la pista: sigue ahí después del primer Deploy, y así sabes que no se envió nada.

![El editor de Cloud Code con cloud/main.js abierto mostrando el hook beforeSave de Task, y la insignia Files pending deploy (1) junto al botón Deploy](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/08-cloud-code-pending.jpg)

**Database Browser: la clase Task.** Seis filas. Las tres más recientes llegaron por el `POST /tasks` del container mientras escribíamos esta sección; la titulada *Deploy the container* se envió con espacios alrededor y se guardó recortada.

Las dos filas sin título visible son tres espacios cada una: se escribieron el 15 de septiembre, antes de desplegar el hook, que es exactamente el agujero que el hook cierra.

![El Database Browser mostrando la clase Task con seis objetos: las columnas title y done inferidas por el primer POST, tres tareas nuevas del 17 de septiembre y dos filas más antiguas con el título en blanco](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/07-database-task.jpg)

## ¿Cuánto dura la URL y cuánto cuesta que sea permanente?

En el plan Free, 60 minutos desde el momento en que empieza el deploy; después el container se destruye y la dashboard muestra **URL Expired**. En el plan Shared, US$ 5 al mes por 0,5 vCPU y 512 MB, la URL es permanente, y el cambio de plan por sí solo volvió a desplegar la app en la misma dirección (todo a 16 de septiembre de 2026).

La hora gratuita es real y es suficiente: cada paso de este post, errores incluidos, cabe en una ventana. No es suficiente para dejar la API corriendo para un cliente, y la plataforma lo dice tal cual: la tarjeta del plan Free dice "Ideal for developing, learning, and prototyping".

Medimos la caducidad al minuto en el worker de [nuestro post sobre procesos siempre activos](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps): deploy iniciado a las 14:55:28, URL desaparecida a las 15:55:27. Esta API mostró el mismo estado **URL Expired** a la mañana siguiente.

A la mañana siguiente cambiamos el plan. Sin ningún clic en Deploy, el log mostró `PREPARING DEPLOYMENT` a las 12:35:32 y `DEPLOYMENT READY` a las 12:36:24 (52 segundos) en la misma URL `b4a.run`, y `deploy-check.sh` volvió a pasar.

![La Overview del container en el plan Shared: US$ 5,00 al mes, 0,5 vCPU y 512 MB, estado Available, el redeploy automático Ready en 52 s, el log terminando en el CMD de uvicorn y Pushed image](/blog/blog-assets/deploy-python-fastapi-dockerfile-database/04-shared-plan-ready.jpg)

## ¿Qué significan 422, 404 y 502 en esta API y cómo se corrigen?

Rompimos el deploy a propósito, y la plataforma lo rompió una vez por nosotros. Cada entrada empieza por lo que verás de verdad.

**`KeyError: 'PARSE_APP_ID'` en cuanto uvicorn importa la app.** `os.environ["PARSE_APP_ID"]` lanza la excepción en el momento del import cuando falta la variable, así que no hay proceso al que llegue el health check. Añade las dos variables en Settings → Environment y despliega otra vez; una variable guardada tiene efecto en el siguiente deploy, no en el que está corriendo.

**`422 {"detail":{"code":142,"error":"title is required."}}` en un título que Pydantic aceptó.** Funciona como debe: el hook del backend lo rechazó. La forma del `422` es distinta de la lista de errores de Pydantic; si tu cliente parsea `detail`, contempla tanto una lista como un objeto.

**`404 {"detail":{"code":101,"error":"Object not found."}}` en `GET /tasks/{id}`.** El id no es el `objectId` de una Task; Parse responde `101` y el container lo mapea a 404. Los ids son cadenas de diez caracteres que distinguen mayúsculas de minúsculas, como `OFTBmFAMs3`.

**`502 {"detail":{"error":"{\"error\":\"unauthorized\"}"}}` en todas las rutas salvo `/healthz`.** Una `PARSE_REST_KEY` incorrecta. El backend responde `unauthorized` sin código de Parse, así que `parse_error` cae en el 502 con el cuerpo crudo como cadena. El health check pasó porque `/healthz` no toca el backend, y la comprobación de la plataforma es una comprobación de puerto de todos modos. Haz que `/healthz` ejecute una query barata para que `deploy-check.sh` y tu propio monitor detecten una clave incorrecta.

**`DEPLOYMENT READY` en una API cuyas rutas fallan todas.** El paso de health de la plataforma comprueba que el puerto responde, no el estado: en nuestro worker, una ruta que devolvía `404` lo pasó. Una clave incorrecta o un handler caído no se detectan, por tanto, en el deploy: ejecuta `deploy-check.sh` contra la URL después de cada deploy.

**`404 not found` en la URL, y la dashboard dice `URL Expired`.** La hora gratuita terminó y el container ya no existe. Vuelve a desplegar para otra hora, o cambia el plan: el cambio de plan redespliega por sí solo.

## ¿Cuándo es esta la respuesta equivocada?

Por nuestro propio manifiesto, un post en el que solo gana la plataforma no se publica, así que aquí está la otra columna.

- **Tu API es CRUD y nada más.** Entonces no necesitas el container: la capa REST del backend *es* la API, y un cliente en Python puede llamarla directamente con los mismos dos headers.
- **Necesitas funciones de Postgres.** Joins entre muchas tablas, funciones de ventana, PostGIS, un schema que migras con Alembic: el backend es Parse Server sobre MongoDB (Postgres en los planes de pago), y su lenguaje de consulta es JSON en `where`, no SQL.
- **Necesitas un worker en segundo plano junto a la API.** Un container es un proceso; un consumidor de cola va en su propio container, como en [nuestro post sobre procesos siempre activos](https://www.back4app.com/blog/run-a-bot-webhook-or-cron-24-7-without-a-vps).
- **Quieres construir las imágenes en local y subirlas.** La vía de imagen solo acepta Docker Hub.

Si ninguno de esos casos te describe, un container de FastAPI sin estado delante de un backend gestionado es la forma que volveríamos a elegir, y el par Pydantic más hook es como lo validaríamos.

## ¿Qué añadirías a la API de tareas a continuación?

Tienes una API que se redespliega en menos de un minuto, una base de datos que nunca aprovisionaste y reglas que sobreviven a que el container sea reemplazado. A partir de aquí:

- **Dale propietarios a las tareas.** La clase `User` de Parse añade endpoints de creación de cuenta e inicio de sesión sin código en el container ([medido, con restablecimiento de contraseña](https://www.back4app.com/blog/add-signup-login-password-reset-without-an-auth-server)); un session token en el header de la solicitud acota cada query una vez cerrada la clase ([la sonda de doce solicitudes](https://www.back4app.com/blog/lock-down-a-backend-your-frontend-talks-to)).
- **Activa el autodeploy.** En un plan de pago, Settings → Build & deploy → Autodeploy; medimos un push hasta producción en 44 segundos en el worker.
- **Haz honesto el health check.** Haz que `/healthz` ejecute `GET /classes/Task?limit=0` contra el backend para que una clave incorrecta aparezca en tu monitorización, no en el primer usuario.

Todo el código de arriba está completo y probado: el [repositorio companion](https://github.com/templates-back4app/fastapi-tasks) incluye `deploy-check.sh`, que ejecuta health, create, read, update, validate y delete contra cualquier URL de deploy que le des.
