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, Next.js con SSR, un worker siempre activo.
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 — 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.
El container es la única pieza para la que construyes una imagen. La base de datos es una URL y 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.
El Dockerfile completo. EXPOSE 8080 es la línea que la plataforma lee para el puerto:
# 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:
# 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):
# 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:
# 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"}# Stack: bash | Endpoint: POST /classes/Task
curl -X POST \
-H "X-Parse-Application-Id: $APP_ID" \
-H "X-Parse-REST-API-Key: $REST_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Write the tutorial", "done": false}' \
"$BASE/classes/Task"
# {"objectId":"…","createdAt":"…"} ← HTTP 201La misma escritura, el mismo backend. El container no añade nada que el curl no envíe.
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:
# 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:
// 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.

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:

¿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.

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.

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.

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

¿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.
- 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
Userde 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); un session token en el header de la solicitud acota cada query una vez cerrada la clase (la sonda de doce solicitudes). - 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
/healthzejecuteGET /classes/Task?limit=0contra 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 incluye deploy-check.sh, que ejecuta health, create, read, update, validate y delete contra cualquier URL de deploy que le des.