a field notebook, not a copy-paste sheet

Build it, understand it,
then deploy it.

Two layers of foundations here: general ones that apply to any web application (Part One), and framework-specific ones that explain why Django, FastAPI, and Flask each feel the way they do (the opening of each of their own parts). Only once both are in place do we write the app — then deploy the exact thing we just built.

Start at the beginning. Nothing below is copy-paste without a reason attached to it.

Open the complete Sprint NOC deployment guide

Part One

General foundations — true of any web application

Eleven ideas. These hold regardless of framework or host — they're what "a web application" and "deploying one" actually mean underneath the tooling.


1.1

Your laptop is a server. It's just a private one.

When you run python manage.py runserver, you are running a real web server — the same kind of thing that serves google.com. The only difference is reachability: 127.0.0.1 means "this machine, and nobody else," so the only browser that can reach it is one running on your laptop. Nothing about the code changes when it becomes "production" — what changes is who can reach it, and whether it keeps running when your laptop is asleep, on a plane, or closed in a bag.

the shape of itDeploying isn't a transformation of your app. It's moving the exact same program onto a machine that (a) stays on, and (b) has a public address. Heroku is that machine — rented by the minute, managed for you.
1.2

What "the internet" does between a browser and your code — HTTP

Every one of these frameworks exists to answer the same repeating event: a browser sends a request — a plain-text message containing a method (GET to fetch something, POST to submit something, and a handful of others), a path (/notes, /), and sometimes a body of data — and expects back a response: a status code (200 for success, 404 for not found, 500 for the server broke) and a body, usually HTML, JSON, or a file. This exchange is HTTP, and it is the entire vocabulary a web framework is built to speak.

A framework's job, stripped to its core, is to save you from parsing that raw text yourself: it hands you a clean object representing the incoming request, and gives you a clean way to construct the outgoing response. Every "view function," "path operation," or "route handler" you write in the sections ahead is just: request in, response out. Nothing more mysterious than that is happening.

1.3

Routing: matching a path to the code that handles it

Since a single app usually needs to answer many different paths (/, /notes, /notes/3) differently, every framework keeps a table mapping path patterns to the function that should run for each. Django calls this table a URLconf, FastAPI and Flask both build it implicitly from decorators placed directly above each function. Different spelling, identical concept: an incoming request's path gets matched against the table, and the first (or best) match's function runs. This is routing, and once you can see it as "just a lookup table," each framework's syntax for declaring routes stops needing to be memorized separately — you're only ever asking "what pattern, which function."

1.4

What Heroku actually is

Heroku is a platform, not a server you configure by hand. You don't SSH in and install Python yourself. Instead, you hand Heroku your source code, and it looks for clues about how to run it — a requirements.txt tells it "this is Python," and from there it builds a container image automatically. That automatic detection step is called a buildpack.

The running container is called a dyno. It's a small, isolated Linux box that starts your app, keeps a log of everything it prints, and gets replaced — not repaired, replaced from scratch — every time you deploy, or every 24 hours regardless, which is why nothing your app needs can live only inside it. That constraint reappears in 1.10.

1.5

Git isn't just history — it's the delivery truck

You already use git to save versions of your code. On Heroku, git does double duty: heroku create adds a second remote repository, alongside GitHub, that lives on Heroku's own servers. When you run git push heroku main, you're pushing a git history to a git server with a build step wired to its receiving end. The push itself triggers buildpack detection, dependency install, and release, automatically, as a side effect of the push completing.

why this mattersThis is why a broken push (a merge conflict, an unstaged file) stops a deploy cold before Heroku even looks at your code — git has to succeed first, because git is the transport.
1.6

Virtual environments — why every project gets its own toolbox

Two projects on the same laptop might need different, incompatible versions of the same library. If Python packages installed globally, the second project you set up could quietly break the first. A virtual environment (venv) is a self-contained folder holding its own copy of Python's package directory, isolated from every other project and from your system Python. Activating it (source venv/bin/activate) just points your terminal's python and pip commands at that isolated copy instead of the system-wide one.

requirements.txt is the exported guest list of exactly what's installed inside that isolated copy — pip freeze > requirements.txt writes it, and it's what tells Heroku's buildpack (1.4) precisely what to install into the dyno so the dyno's Python environment matches your isolated one.

1.7

The dev server is a toy. gunicorn is the real thing.

Django's runserver, Flask's app.run(), and FastAPI's built-in dev mode all say so themselves in their own warnings: not for production. They handle requests one at a time, reload on every code change (slow, and a security hole if left on), and weren't built to survive a crash gracefully.

gunicorn ("Green Unicorn") is a production-grade process manager. It starts several independent worker processes, each capable of handling requests, and if one worker crashes, gunicorn restarts it without the whole app going down. This is the piece that turns "code that runs" into "code that stays up."

gunicorn was originally built for WSGI apps (Web Server Gateway Interface — the standard Django and Flask both speak: one request in, one response out, synchronously). FastAPI speaks a newer standard called ASGI (Asynchronous Server Gateway Interface), letting a single worker juggle many requests at once instead of blocking on each one. gunicorn doesn't speak ASGI natively — it needs a translator, uvicorn. You'll see this pairing, gunicorn ... -k uvicorn.workers.UvicornWorker, in the FastAPI section.

1.8

Secrets and settings live outside the code, not inside it

On your laptop, it's tempting to just write SECRET_KEY = "abc123" directly in a settings file. Two problems surface the moment you deploy: first, that file is in git, which means the secret is too — anyone with repo access has it permanently, even after you "remove" it (it's still in history). Second, the correct value differs by environment: your laptop wants DEBUG=True so you see full error pages; a live app never should, because a stranger on the internet could read your database structure off a stack trace.

The fix is environment variables — values that live outside the file, injected by whatever is running the process. Heroku calls these config vars; you set them with heroku config:set KEY=value and your code reads them with os.environ (or a small helper library) instead of hardcoding them. This single habit is what lets the identical codebase run correctly on your laptop and on Heroku — only the environment around it changes.

1.9

The Procfile: telling Heroku what "running" means

A buildpack can figure out how to install your app, but not what command actually starts it — that's genuinely ambiguous (is it a web server? a background worker? both?). The Procfile (no file extension, sitting in your project root) answers that in plain text, one line per process type. web: is the only process type Heroku's router sends internet traffic to — the command that follows must bind to the port number Heroku hands it at runtime through $PORT, which gunicorn does automatically. A release: line, if present, runs once, before the new version goes live, and if it fails, the old version keeps serving traffic instead of a broken one taking over — this is where database migrations belong.

1.10

Static files: why a "file that just sits there" needs help

A CSS file or a logo image doesn't need Python to run — it just needs to be handed to the browser byte-for-byte. The dev server does this invisibly, which quietly hides a real problem: in production, gunicorn's workers are busy running Python, and handing them the job of serving thousands of static file requests wastes the exact resource (worker processes) that should be handling actual app logic.

whitenoise (used in the Django section) solves this without needing a separate file-hosting service: it sits in front of the app as middleware and serves pre-compressed, cache-friendly static files efficiently, while still living inside the one dyno you're already paying for.

1.11

The database lives outside the dyno, on purpose

Recall 1.4: dynos get thrown away and rebuilt regularly. If your database were a file sitting inside the dyno (the way SQLite's db.sqlite3 sits on your laptop), every restart would silently wipe it. Heroku Postgres exists specifically to live outside that lifecycle — a separate, persistent service dynos connect to over the network, addressed by a single connection string Heroku injects as the DATABASE_URL config var the moment you attach the add-on.

Migrations are how your database's actual structure (tables, columns) stays in sync with what your code expects it to look like — each migration is a small, ordered, recorded change. Running them as the release: step (1.9) means the database is always updated to match the code about to start serving requests, in the correct order, every time.

— end of Part One —

Everything above is framework-agnostic. Each part below opens with a second, smaller set of foundations — specific to that framework's own philosophy — before a single line of the application gets written.


Part Two
DJ

Django

Django's own foundations, then building a real app from an empty folder, then deploying the exact thing you built.

2.1

Django's own foundations

a.

MVT — Django's shape for 1.2 and 1.3

Django names the request/response idea from 1.2 as three roles: a Model (the data — see c. below), a View (the function that receives the request and decides what to send back — 1.2's "request in, response out," with a name), and a Template (the HTML that gets filled with data). Nearly every question of "where does this code go" in Django resolves to picking one of these three.

b.

Project vs. App — one house, many rooms

A Django project is the whole site — its settings, its top-level URL table. An app is one self-contained feature inside it (a blog, a notes tool, user accounts) meant to be reusable and pluggable. This is why startproject and startapp are two different commands, and why a new app must be explicitly registered in INSTALLED_APPS before Django will notice it exists.

c.

The ORM — Python classes that are also database tables

Writing raw SQL for every query gets repetitive and error-prone. Django's Object-Relational Mapper lets you describe a table as a Python class (a Model) with fields as class attributes, and it generates the SQL for you. A migration (1.11) is Django's recorded diff between what your models say the database should look like and what it currently does.

d.

The admin site — a UI you didn't write

Because your models already fully describe your data's shape, Django can generate a working create/edit/delete interface for them automatically. This is /admin/ — genuinely free, and worth seeing work before you build any UI of your own.

2.2

Building the application

A small notes app — enough surface area to touch models, the ORM, a view, routing, and the admin, without becoming a tutorial about Django itself.

01

Isolate the project, install Django

The virtual environment from 1.6, created fresh for this one project.

terminal
$ mkdir notes-app && cd notes-app
$ python3 -m venv venv
$ source venv/bin/activate      # Windows: venv\Scripts\activate
$ pip install django
02

Create the project, then the app

Two commands, matching 2.1.b exactly: the project is the house, notes is one room in it. The trailing dot on startproject means "here, don't nest it in another folder."

terminal
$ django-admin startproject notesproject .
$ python manage.py startapp notes
$ python manage.py migrate    # applies Django's own built-in models first
03

Register the app

This is 2.1.b's "explicit registration" in practice — Django won't discover notes on its own.

notesproject/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "notes",   # the new room in the house
]
04

The Model — a Python class that becomes a table

2.1.c, written out. Each class attribute becomes a column; Django infers the SQL column type from the field type you chose.

notes/models.py
from django.db import models

class Note(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField(blank=True)
    created = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.title
05

Generate and apply the migration

makemigrations writes the diff file (1.11) by comparing your models to the last known database state; migrate actually applies it, creating the real table.

terminal
$ python manage.py makemigrations notes
$ python manage.py migrate
06

The View — request in, response out (1.2), by another name

Nothing here is Django magic — it's a plain function. It receives the request object 1.2 described, queries the database through the ORM (2.1.c), and returns an HttpResponse.

notes/views.py
from django.http import HttpResponse
from .models import Note

def index(request):
    titles = ", ".join(n.title for n in Note.objects.all()) or "no notes yet"
    return HttpResponse(f"Notes app is live. Notes: {titles}")
07

Wire it into the routing table

This is 1.3, Django's dialect: urlpatterns is the lookup table, read top to bottom, first match wins.

notesproject/urls.py
from django.contrib import admin
from django.urls import path
from notes.views import index

urlpatterns = [
    path("admin/", admin.site.urls),
    path("", index),
]
08

Run it locally, then see the admin work for free

This is 1.1's "private server," running. createsuperuser gives you a login for 2.1.d's automatic UI — register the model once, and full create/edit/delete exists without a line of view or template code.

terminal + notes/admin.py
# notes/admin.py
from django.contrib import admin
from .models import Note
admin.site.register(Note)

# terminal
$ python manage.py createsuperuser
$ python manage.py runserver
# visit 127.0.0.1:8000/        — your view
# visit 127.0.0.1:8000/admin/  — full CRUD, unwritten by you
try itAdd a note through /admin/, then reload / — the view queries the same database the admin just wrote to. That round trip is the whole MVT loop from 2.1.a, working.
2.3

Deploying it

The application above, unchanged in behavior — only made to read its configuration from the environment (1.8) instead of assuming it's always on your laptop.

01

Add the production dependencies

gunicorn is the real server (1.7); whitenoise serves static files (1.10); dj-database-url and psycopg2-binary connect to Postgres (1.11).

requirements.txt
$ pip install gunicorn whitenoise dj-database-url psycopg2-binary python-decouple
$ pip freeze > requirements.txt
02

Procfile

1.9, literally: release applies the migration from step 05 above before anything new goes live; web starts gunicorn against Django's WSGI entry point.

Procfile
release: python manage.py migrate
web: gunicorn notesproject.wsgi --log-file -
03

settings.py — swap hardcoded values for environment reads

Only what 1.8 and 1.10 require changes. Everything from Part 2.2 — the model, the view, the URL — stays exactly as written.

notesproject/settings.py — additions
import dj_database_url
from decouple import config

SECRET_KEY = config("SECRET_KEY")                          # 1.8
DEBUG = config("DEBUG", default=False, cast=bool)      # 1.8
ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="").split(",")

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "whitenoise.middleware.WhiteNoiseMiddleware",  # 1.10 — right after security
    # ...the rest, unchanged
]

DATABASES = {                                            # 1.11
    "default": dj_database_url.config(
        default="sqlite:///db.sqlite3",                # laptop fallback
        conn_max_age=600,
        ssl_require=not DEBUG,
    )
}

STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
04

Create the dyno, attach a database, deploy

Each line maps to a numbered idea: create the container (1.4), attach persistent storage outside it (1.11), set the values the code now expects (1.8), push to build and start it (1.4, 1.5), then run one command inside a fresh dyno to get your first admin login live.

terminal
$ heroku create notes-app-daviq
$ heroku addons:create heroku-postgresql:essential-0
$ heroku config:set SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(50))')"
$ heroku config:set DEBUG=False ALLOWED_HOSTS=notes-app-daviq.herokuapp.com
$ git init && git add . && git commit -m "Notes app, ready for Heroku"
$ git push heroku main
$ heroku run python manage.py createsuperuser
!
If it breaks: a missing ALLOWED_HOSTS entry throws DisallowedHost — that's 1.8's environment-config working correctly, just missing a value. Add every domain the app answers to.
Part Three
FA

FastAPI

FastAPI's own foundations, then building an API from an empty folder, then deploying it.

3.1

FastAPI's own foundations

a.

Type hints are validation, not decoration

Python type hints (title: str) are normally just a hint for humans and editors. FastAPI actually reads them at runtime: declare a function parameter's type, and FastAPI checks incoming data against it automatically, rejecting anything that doesn't match before your function even runs. This is the single idea the whole framework is built around.

b.

Pydantic models — the shape of your data, declared once

Instead of a Django-style Model tied to a database table (2.1.c), a Pydantic BaseModel just describes the shape of a piece of data — what fields, what types. FastAPI uses that same declaration for three jobs at once: validating incoming JSON, shaping outgoing JSON, and generating documentation (d, below).

c.

Path operations — 1.3's routing table, as decorators

@app.get("/notes") registers a function against a path and an HTTP method (1.2) in one line — the same routing-table idea as Django's URLconf, just declared directly above the function it points to instead of in a separate file.

d.

Interactive docs — a UI you didn't write, FastAPI's version

Because every path operation's inputs and outputs are already fully typed (a and b), FastAPI can generate a live, clickable API explorer from that information alone — no separate documentation to maintain. This is /docs, and it's worth seeing before you build anything that calls the API from outside.

3.2

Building the application

The same notes idea as the Django section, as an API instead of a page — enough to touch a path operation, a Pydantic model, and the automatic docs.

01

Isolate the project, install FastAPI and uvicorn

The uvicorn/ASGI relationship from 1.7 — needed locally too, since FastAPI has no dev server of its own.

terminal
$ mkdir notes-api && cd notes-api
$ python3 -m venv venv && source venv/bin/activate
$ pip install fastapi "uvicorn[standard]"
02

Declare the data shape, then the routes

This is 3.1.b and 3.1.c in one file — no separate models.py or urls.py, because a small FastAPI app doesn't need the project/app split Django uses (2.1.b). An in-memory list stands in for a database here; note that it resets whenever the process restarts, which is exactly the dyno-lifecycle point from 1.4 and 1.11 made concrete — a real deployment would replace this list with Postgres.

main.py
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()
notes: list[dict] = []

class Note(BaseModel):        # 3.1.b — the shape of a note
    title: str
    body: str = ""

@app.get("/")              # 3.1.c — path operation
def root():
    return {"status": "live", "note_count": len(notes)}

@app.get("/notes")
def list_notes():
    return notes

@app.post("/notes")
def create_note(note: Note):   # 3.1.a — this parameter is now validated automatically
    notes.append(note.model_dump())
    return note
03

Run it locally, then watch validation happen for free

--reload is the dev-only convenience 1.7 warned you not to ship. Visit /docs for 3.1.d's generated explorer, and try posting a note with a missing title — FastAPI rejects it before create_note ever runs, purely from the type hint.

terminal
$ uvicorn main:app --reload
# visit 127.0.0.1:8000/       — the root path operation
# visit 127.0.0.1:8000/docs   — interactive docs, generated from your type hints
try itIn /docs, expand POST /notes, click "Try it out," and submit. Then GET /notes — same request/response loop as 1.2, just JSON instead of HTML.
3.3

Deploying it

One idea changes from Django's deploy section: ASGI needs the uvicorn translator (1.7). Everything else in Part One applies unchanged.

why this is shorter than Django'sThere's no settings module and no ORM standing between the code and Part One's ideas — less framework machinery means less to reconfigure for production.
01

Add the production dependencies

requirements.txt
fastapi
uvicorn[standard]
gunicorn
# add sqlalchemy + alembic only once you replace the in-memory list with Postgres (1.11)
02

Procfile

Read -k literally: "use this worker kind." It hands each gunicorn worker slot to uvicorn's ASGI implementation instead of gunicorn's own default WSGI one — the translator from 1.7, made concrete.

Procfile
web: gunicorn main:app -k uvicorn.workers.UvicornWorker --log-file -
03

Create the dyno, deploy

The in-memory list from 3.2 means there's no database step here yet — nothing in 1.11 to attach until you add one.

terminal
$ heroku create api-daviq
$ git init && git add . && git commit -m "Notes API, ready for Heroku"
$ git push heroku main
$ heroku open
$ heroku logs --tail
!
If it breaks: installing plain uvicorn instead of uvicorn[standard] skips the extras UvicornWorker expects — the translator from 1.7 needs the full package.
Part Four
FL

Flask

Flask's own foundations, then building the smallest complete version of the same app, then deploying it.

4.1

Flask's own foundations

a.

Micro-framework — explicit over included

Django ships an ORM, an admin site, and a project structure opinion (2.1). Flask ships almost nothing beyond routing (1.3) and request/response handling (1.2) — everything else (a database layer, forms, authentication) is a separate package you choose and add yourself. Neither approach is "better"; Flask trades built-in structure for fewer assumptions about what you're building.

b.

Routes as decorators, methods stated explicitly

@app.route("/notes", methods=["GET", "POST"]) is 1.3's routing table again, but Flask makes you name which HTTP methods (1.2) a route accepts — nothing is inferred for you, matching a's philosophy.

c.

The request object is global-looking, but request-scoped

Flask's request is imported directly rather than passed as a function argument, which reads like a global variable but actually holds the current request's data only — Flask swaps what it points to behind the scenes for each incoming request. Convenient, but worth knowing it isn't literally global state.

d.

Jinja2 — templates, if you want HTML back

Flask includes Jinja2 templating (the same engine Django's templates are modeled after) for when a route should return rendered HTML instead of raw text or JSON — not used below, since this example stays deliberately minimal, but it's the natural next step past return "a string".

4.2

Building the application

01

Isolate the project, install Flask

terminal
$ mkdir notes-tool && cd notes-tool
$ python3 -m venv venv && source venv/bin/activate
$ pip install flask
02

One route, both methods, handled explicitly

4.1.b and 4.1.c in practice: the method is checked by hand (nothing auto-splits GET from POST the way separate FastAPI decorators do), and request.form or request.json is where 1.2's incoming data actually shows up.

app.py
from flask import Flask, request, jsonify

app = Flask(__name__)
notes = []

@app.route("/")
def index():
    return f"Notes app is live. {len(notes)} note(s)."

@app.route("/notes", methods=["GET", "POST"])   # 4.1.b — methods named explicitly
def notes_route():
    if request.method == "POST":
        note = {"title": request.form.get("title", "")}  # 4.1.c — request, request-scoped
        notes.append(note)
        return jsonify(note), 201
    return jsonify(notes)
03

Run it locally

--debug turns on the dev-only reload-and-full-error-page behavior 1.7 said never to ship.

terminal
$ flask --app app run --debug
# visit 127.0.0.1:5000/
# curl -X POST -d title="first note" 127.0.0.1:5000/notes
4.3

Deploying it

01

Add the production dependency

One package. No ASGI translator needed (1.7) — Flask speaks WSGI directly, same as gunicorn's default.

requirements.txt
flask
gunicorn
# add Flask-SQLAlchemy + Flask-Migrate only once you replace the in-memory list with Postgres (1.11)
02

Procfile

Procfile
web: gunicorn app:app --log-file -
03

Read the secret key from the environment (1.8), then deploy

app.py — one addition, plus terminal
# add near the top of app.py
import os
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY")

# terminal
$ heroku create tools-daviq
$ heroku config:set SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(50))')"
$ git init && git add . && git commit -m "Notes tool, ready for Heroku"
$ git push heroku main
$ heroku open
!
If it breaks: once a real database gets added, use psycopg2-binary, not plain psycopg2 — the source build needs system tools Heroku's dyno doesn't have (a 1.11 detail, not specific to Flask).
Part Five

Domains: DNS and TLS, from the ground up

Applies identically to whichever of the three apps above you deployed — only the app name in step 01 changes.

5.1 — What a domain name actually is. Computers address each other by IP address, not by name. A domain name is a lookup table entry: "when someone asks for notesapp.davidsdomain.com, tell them where to actually go." DNS (the Domain Name System) is that global lookup table, and Cloudflare, once your domain's nameservers point to it, is where you write the entries for your slice of it.

5.2 — Why Heroku hands you a name, not an address. Recall 1.4: dynos are containers that get destroyed and recreated. If Heroku gave you a fixed IP address to point at, that address would become invalid the moment your app's underlying infrastructure shifted — which happens often, invisibly, outside your control. Instead, Heroku gives you a hostname (ending in herokudns.com) that it keeps pointing at the correct address on its end, permanently. Your job is only to point your domain at that hostname, using a CNAME record — a DNS entry that means "this name is really just another name for that name," rather than a fixed address.

5.3 — Why the root domain is the awkward case. DNS technically forbids a CNAME on the bare root of a domain (davidsdomain.com with no subdomain) because a root position is also where other required records live, and the two can't coexist under the original spec. Cloudflare's answer is CNAME flattening: you write it as a CNAME in their dashboard, and Cloudflare quietly serves it to the internet as the record type that's actually legal at the root, while keeping it behaving like a CNAME. It's a workaround for a decades-old rule, not a special Heroku requirement.

5.4 — What TLS/SSL is actually encrypting. "HTTPS" means the connection between two specific points is encrypted — nothing more, nothing less. With Cloudflare sitting in front of your app, there are two hops: browser→Cloudflare, and Cloudflare→Heroku. "Flexible" SSL only encrypts the first hop and sends the second one in plain text — which causes real problems the moment your app itself insists on HTTPS (it sees a plain-text request arrive and redirects to HTTPS, Cloudflare re-requests the same way, and you get a loop). Full (strict) encrypts and verifies both hops, which works cleanly because Heroku already issues your app a real, auto-renewing certificate the instant you add a custom domain — there's nothing left unverified.

01

Add the domain in Heroku

heroku domains:add app.yourdomain.com — returns the herokudns.com hostname from 5.2.

02

CNAME it in Cloudflare

DNS → Add record → CNAME → your subdomain (or @ for the root, per 5.3) → the herokudns.com target.

03

SSL/TLS → Full (strict)

Per 5.4 — encrypts and verifies both hops instead of just the first one.

!
One more link back to Part One: add the new domain to ALLOWED_HOSTS (Django) or wherever your app checks the request host — 1.8's environment config, again. Django will otherwise refuse requests arriving under a hostname it wasn't told to expect.
once the ideas are yours — quick reference

Command cheatsheet

Local dev

python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip freeze > requirements.txt

App lifecycle

heroku login
heroku create <app-name>
heroku apps
git push heroku main

Config & data

heroku config
heroku config:set KEY=value
heroku addons:create heroku-postgresql:essential-0
heroku run python manage.py <cmd>

Operations

heroku logs --tail
heroku ps
heroku ps:scale web=1
heroku restart

Releases

heroku releases
heroku rollback <version>

Domains

heroku domains:add <domain>
heroku domains
heroku domains:remove <domain>
Small builds

Three tiny apps, ready for Heroku

Each example returns a health response at /. Build these in three separate folders and deploy each folder as its own Heroku app. The code is intentionally small so the deployment shape is easy to see.

A

Django

01

Build the project

Run these commands inside a new django-app folder.

terminal
mkdir django-app && cd django-app
python -m venv .venv
.venv\Scripts\activate              # Windows
source .venv/bin/activate            # macOS/Linux
pip install django gunicorn
django-admin startproject config .
python manage.py migrate
python manage.py runserver
02

Add the production command

Create a file named Procfile beside manage.py.

Procfile
web: gunicorn config.wsgi --log-file -
03

Deploy it

terminal
pip freeze > requirements.txt
heroku login
heroku create my-django-example
git init && git add . && git commit -m "first Django app"
git push heroku main
heroku open
B

FastAPI

01

Create main.py

main.py
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def health_check():
    return {"status": "ok", "framework": "FastAPI"}
02

Add dependencies and Procfile

requirements.txt
fastapi
uvicorn[standard]
gunicorn
Procfile
web: gunicorn main:app -k uvicorn.workers.UvicornWorker --log-file -
03

Deploy it

terminal
heroku create my-fastapi-example
git init && git add . && git commit -m "first FastAPI app"
git push heroku main
heroku open
C

Flask

01

Create app.py

app.py
from flask import Flask

app = Flask(__name__)

@app.get("/")
def health_check():
    return {"status": "ok", "framework": "Flask"}
02

Add dependencies and Procfile

requirements.txt
flask
gunicorn
Procfile
web: gunicorn app:app --log-file -
03

Deploy it

terminal
heroku create my-flask-example
git init && git add . && git commit -m "first Flask app"
git push heroku main
heroku open
!
Important: use a different folder and Heroku app name for each example. Heroku receives the Procfile and requirements.txt from the folder you push. If your default branch is master, use git push heroku master instead of main.
field guide companion

Sprint NOC Ticketing on Heroku

The main guide above teaches small framework examples. The companion guide applies the same Heroku ideas to a real Django operations stack, written for someone who already understands VMs, systemd, nginx, and SSH.

Translate VM habits into Heroku concepts

See how dynos replace hand-maintained VMs, git push replaces a deployment script, buildpacks replace provisioning, Procfile process types replace systemd units, and config vars replace environment files.

Deploy the actual NOC stack

The guide documents the Sprint NOC repository shape and its Django 5, Channels/Daphne, Postgres, Redis, Celery, and beat processes. It explains what each process does and why the web process uses ASGI for live updates.

Operate and troubleshoot it

Follow the real workflow for attaching services, setting secrets, running migrations, seeding data, scaling process types, reading logs, checking dynos, and diagnosing common Heroku errors such as H10, H12, H14, and R14.

Ship safely while the team is live

Keep additive migrations separate from destructive changes, back up Postgres before risky work, test on staging, and move uploaded media to durable object storage because dyno disk is disposable.

Read the full NOC field guide, including every command and reference table

companion expansion

Extended notes for deeper understanding

Your original guide stays intact above. This companion section adds extra framing you can use when teaching someone else, or when revisiting the same concepts months later.

How to reason about failures in production

When something fails after deployment, classify it first before touching code: startup failure (the process never boots), request failure (a route returns a bad response), or integration failure (database, DNS, or TLS mismatch). This reduces panic and gives you a smallest-first debug path. Start with logs, then configuration, then code. Most first-time deploy issues are configuration mismatches, not logic bugs.

The one-machine illusion

Local development feels like one machine doing everything, but production is many cooperating layers. Browser, DNS, TLS edge, router, dyno, and data service each have separate responsibilities. If you name the layer that is currently failing, you can ask sharper questions and fix problems faster. This single habit scales from side-projects to large systems.

Why repeatable deploys matter more than fast deploys

A deploy pipeline is successful when a fresh machine can rebuild the same app from source and configuration every time. That is why requirements, Procfile commands, and environment variables are explicit. A slower deploy that is reproducible beats a fast deploy that only works on one laptop.

Scaling mindset before scaling infrastructure

Before adding more dynos or services, remove avoidable bottlenecks: static assets should be cache-friendly, long-running tasks should not block web workers, and logs should be structured enough to search quickly. Good operational habits usually postpone the need for expensive scaling while improving reliability immediately.