Skip to content
Cloud & Infra Intermediate Tutorial

Deploy a FastAPI App to Fly.io with Managed Postgres

Scaffold a notes API with FastAPI and SQLAlchemy, containerize it, attach a Fly Postgres cluster, and ship the whole thing to Fly.io Machines in under 15 minutes.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Jun 11, 2026 · 8 min read

What You'll Build

A FastAPI notes API (create and list notes) running on Fly.io Machines, backed by a Fly Postgres cluster, and served over HTTPS at https://<your-app>.fly.dev. You'll also understand how Fly.io's secrets model and Postgres attachment workflow fit together so you can apply the same pattern to any data-driven Python service.

Prerequisites

Requirement Version / Notes
Python 3.11 or 3.12
Docker Desktop 4.x — optional; only needed for local image testing. fly deploy uses Fly.io's remote builders by default and does not require a local Docker daemon.
flyctl Latest stable (install below)
Fly.io account A credit card is required to sign up; the resources used in this tutorial fit within Fly.io's free compute allowances

Install flyctl. On macOS, Homebrew is the safest path:

brew install flyctl

On Linux (or macOS without Homebrew), use the official installer script:

curl -L https://fly.io/install.sh | sh

Then log in:

fly auth login

1. Scaffold the FastAPI App

Create the project layout:

mkdir notes-api && cd notes-api
mkdir app && touch app/__init__.py app/main.py requirements.txt Dockerfile

requirements.txt

fastapi>=0.110.0
uvicorn[standard]>=0.29.0
sqlalchemy>=2.0.0
psycopg2-binary>=2.9.0

app/main.py — falls back to SQLite when DATABASE_URL is absent so you can develop locally without Docker:

import os
from fastapi import FastAPI, Depends
from pydantic import BaseModel
from sqlalchemy import Column, Integer, String, create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker

# Fly.io injects DATABASE_URL as postgres://...
# SQLAlchemy 2.x requires the postgresql:// scheme.
_raw_url = os.environ.get("DATABASE_URL", "sqlite:///./dev.db")
DATABASE_URL = _raw_url.replace("postgres://", "postgresql://", 1)

# SQLite requires check_same_thread=False to allow FastAPI's threaded
# request handling to share a connection across threads. This kwarg is
# silently ignored by psycopg2 and other PostgreSQL drivers.
_connect_args = {"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
engine = create_engine(DATABASE_URL, pool_pre_ping=True, connect_args=_connect_args)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)


class Base(DeclarativeBase):
    pass


class Note(Base):
    __tablename__ = "notes"

    id = Column(Integer, primary_key=True, index=True)
    text = Column(String, nullable=False)


Base.metadata.create_all(bind=engine)

app = FastAPI(title="Notes API")


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


class NoteCreate(BaseModel):
    text: str


class NoteOut(BaseModel):
    id: int
    text: str

    model_config = {"from_attributes": True}  # Pydantic v2


@app.get("/health")
def health():
    return {"status": "ok"}


@app.get("/notes", response_model=list[NoteOut])
def list_notes(db: Session = Depends(get_db)):
    return db.query(Note).all()


@app.post("/notes", response_model=NoteOut, status_code=201)
def create_note(payload: NoteCreate, db: Session = Depends(get_db)):
    note = Note(text=payload.text)
    db.add(note)
    db.commit()
    db.refresh(note)
    return note

Design note: Base.metadata.create_all creates tables at startup if they don't exist. This is fine for a tutorial, but production apps should manage schema changes with Alembic migrations.

2. Write the Dockerfile

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8080

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

Fly.io routes external HTTPS traffic to each Machine's internal_port, which defaults to 8080. The EXPOSE hint and the --port 8080 in CMD must agree with that value in fly.toml (confirmed in the next step).

3. Initialize the Fly App

From the project root, run fly launch with --no-deploy so you can attach Postgres before the first real deployment:

fly launch --no-deploy

flyctl will:

  1. Detect the Dockerfile.
  2. Prompt for an app name (e.g. notes-api) and a primary region.
  3. Ask if you want Postgres or Redis — answer No; you'll provision Postgres manually in the next step.
  4. Write a fly.toml to disk.

Note: The exact interactive prompts vary between flyctl releases. Newer versions may open a browser-based configuration page instead of CLI prompts. In either case, decline any Postgres or Redis add-ons when offered.

Open fly.toml and verify internal_port is 8080:

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = "stop"
  auto_start_machines = true
  min_machines_running = 0

If flyctl inferred a different port, update it to 8080 before continuing. Note that auto_stop_machines accepts the string values "stop", "suspend", or "off"; the older boolean true maps to "stop" but is deprecated.

4. Provision Fly Postgres

Create a single-node Postgres cluster in the same region as your app (substitute your region code for iad):

Advertisement
fly postgres create \
  --name notes-db \
  --region iad \
  --vm-size shared-cpu-1x \
  --volume-size 1 \
  --initial-cluster-size 1

Cost note: A shared-cpu-1x instance with a 1 GB volume is small enough to fit within Fly.io's free compute allowances, which are included with all accounts. See fly.io/docs/about/pricing for current details; pricing tiers change periodically.

Now attach the cluster to your app. This command creates a dedicated Postgres user and database, then injects DATABASE_URL as an encrypted secret on your app:

fly postgres attach notes-db --app notes-api

Confirm the secret was registered:

fly secrets list --app notes-api

DATABASE_URL should appear in the output. The value is redacted—Fly.io never re-exposes secret values after they are set. Fly.io uses private WireGuard networking between apps in the same organization, so the .internal hostname in DATABASE_URL is reachable from your app but not from the public internet.

5. Deploy

fly deploy

flyctl archives your source directory, ships it to Fly.io's remote builders, pushes the resulting image to the registry, and rolls out the new Machine. No local Docker daemon is required. The first deploy takes roughly 90 seconds; subsequent ones are faster thanks to layer caching.

Stream the startup logs to confirm everything came up cleanly:

fly logs --app notes-api

Expected output:

INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8080 (Press CTRL+C to quit)

Verify It Works

Get your assigned hostname:

fly status --app notes-api

Then exercise the API (replace notes-api with your actual app name):

# Health check
curl https://notes-api.fly.dev/health

# Create a note
curl -X POST https://notes-api.fly.dev/notes \
  -H "Content-Type: application/json" \
  -d '{"text": "Deployed on Fly.io!"}'

# List all notes
curl https://notes-api.fly.dev/notes

Expected responses:

{"status":"ok"}

{"id":1,"text":"Deployed on Fly.io!"}

[{"id":1,"text":"Deployed on Fly.io!"}]

Visit https://notes-api.fly.dev/docs to explore the auto-generated Swagger UI.

Troubleshooting

fly deploy fails with health check errors or the Machine never becomes healthy.
The most common cause is a port mismatch. Verify that CMD in your Dockerfile uses --port 8080 and fly.toml has internal_port = 8080. Run fly logs --app notes-api immediately after a failed deploy to see the actual Python traceback.

sqlalchemy.exc.OperationalError: could not connect to server
The app cannot reach Postgres. Check that fly postgres attach completed without errors and that fly secrets list --app notes-api shows DATABASE_URL. If you ran fly deploy before attaching, the secret was absent at boot—re-run fly deploy after the attachment and the fresh Machine will pick up the secret.

ValueError: invalid scheme: postgres:// from SQLAlchemy
SQLAlchemy 2.x rejects the postgres:// prefix. The replace("postgres://", "postgresql://", 1) line in app/main.py handles this. Double-check that line is present and runs before create_engine.

First request after idle takes 2–4 seconds.
With auto_stop_machines = "stop" and min_machines_running = 0, Fly.io stops the VM when there is no traffic. Set min_machines_running = 1 in fly.toml and redeploy to keep one instance always warm. This counts against your free compute allowance.

Next Steps

  • Schema migrations: Add Alembic (alembic init alembic, alembic revision --autogenerate) and run alembic upgrade head via a release command in fly.toml.
  • Custom domain: fly certs add yourdomain.com provisions a free Let's Encrypt TLS certificate automatically.
  • Additional secrets: Store API keys and other sensitive config with fly secrets set KEY=value. Never commit secrets to your repository.
  • High availability: For automatic failover, provision a multi-node cluster from the start by passing --initial-cluster-size 2 to fly postgres create. To add HA replicas to an existing cluster, follow the Fly Postgres high-availability guide.
  • CI/CD: Use the official superfly/flyctl-actions GitHub Action to run fly deploy on every push to main.
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.

Discussion 0

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading