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.
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_allcreates 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:
- Detect the
Dockerfile. - Prompt for an app name (e.g.
notes-api) and a primary region. - Ask if you want Postgres or Redis — answer No; you'll provision Postgres manually in the next step.
- Write a
fly.tomlto 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):
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-1xinstance 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 runalembic upgrade headvia a release command infly.toml. - Custom domain:
fly certs add yourdomain.comprovisions 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 2tofly postgres create. To add HA replicas to an existing cluster, follow the Fly Postgres high-availability guide. - CI/CD: Use the official
superfly/flyctl-actionsGitHub Action to runfly deployon every push tomain.
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
No comments yet
Be the first to weigh in.