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.
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.
Eleven ideas. These hold regardless of framework or host — they're what "a web application" and "deploying one" actually mean underneath the tooling.
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.
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.
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."
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.
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.
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.
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.
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.
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.
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.
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.
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.
Django's own foundations, then building a real app from an empty folder, then deploying the exact thing you built.
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.
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.
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.
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.
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.
The virtual environment from 1.6, created fresh for this one project.
$ mkdir notes-app && cd notes-app
$ python3 -m venv venv
$ source venv/bin/activate # Windows: venv\Scripts\activate
$ pip install djangoTwo 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."
$ django-admin startproject notesproject .
$ python manage.py startapp notes
$ python manage.py migrate # applies Django's own built-in models firstThis is 2.1.b's "explicit registration" in practice — Django won't discover notes on its own.
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
]2.1.c, written out. Each class attribute becomes a column; Django infers the SQL column type from the field type you chose.
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.titlemakemigrations writes the diff file (1.11) by comparing your models to the last known database state; migrate actually applies it, creating the real table.
$ python manage.py makemigrations notes
$ python manage.py migrateNothing 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.
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}")This is 1.3, Django's dialect: urlpatterns is the lookup table, read top to bottom, first match wins.
from django.contrib import admin
from django.urls import path
from notes.views import index
urlpatterns = [
path("admin/", admin.site.urls),
path("", index),
]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.
# 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/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.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.
gunicorn is the real server (1.7); whitenoise serves static files (1.10); dj-database-url and psycopg2-binary connect to Postgres (1.11).
$ pip install gunicorn whitenoise dj-database-url psycopg2-binary python-decouple
$ pip freeze > requirements.txt1.9, literally: release applies the migration from step 05 above before anything new goes live; web starts gunicorn against Django's WSGI entry point.
release: python manage.py migrate
web: gunicorn notesproject.wsgi --log-file -Only what 1.8 and 1.10 require changes. Everything from Part 2.2 — the model, the view, the URL — stays exactly as written.
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"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.
$ 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 createsuperuserALLOWED_HOSTS entry throws DisallowedHost — that's 1.8's environment-config working correctly, just missing a value. Add every domain the app answers to.FastAPI's own foundations, then building an API from an empty folder, then deploying it.
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.
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).
@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.
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.
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.
The uvicorn/ASGI relationship from 1.7 — needed locally too, since FastAPI has no dev server of its own.
$ mkdir notes-api && cd notes-api
$ python3 -m venv venv && source venv/bin/activate
$ pip install fastapi "uvicorn[standard]"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.
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--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.
$ 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/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.One idea changes from Django's deploy section: ASGI needs the uvicorn translator (1.7). Everything else in Part One applies unchanged.
fastapi
uvicorn[standard]
gunicorn
# add sqlalchemy + alembic only once you replace the in-memory list with Postgres (1.11)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.
web: gunicorn main:app -k uvicorn.workers.UvicornWorker --log-file -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.
$ heroku create api-daviq
$ git init && git add . && git commit -m "Notes API, ready for Heroku"
$ git push heroku main
$ heroku open
$ heroku logs --tailuvicorn instead of uvicorn[standard] skips the extras UvicornWorker expects — the translator from 1.7 needs the full package.Flask's own foundations, then building the smallest complete version of the same app, then deploying it.
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.
@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.
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.
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".
$ mkdir notes-tool && cd notes-tool
$ python3 -m venv venv && source venv/bin/activate
$ pip install flask4.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.
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)--debug turns on the dev-only reload-and-full-error-page behavior 1.7 said never to ship.
$ flask --app app run --debug
# visit 127.0.0.1:5000/
# curl -X POST -d title="first note" 127.0.0.1:5000/notesOne package. No ASGI translator needed (1.7) — Flask speaks WSGI directly, same as gunicorn's default.
flask
gunicorn
# add Flask-SQLAlchemy + Flask-Migrate only once you replace the in-memory list with Postgres (1.11)web: gunicorn app:app --log-file -# 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 openpsycopg2-binary, not plain psycopg2 — the source build needs system tools Heroku's dyno doesn't have (a 1.11 detail, not specific to Flask).python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
pip freeze > requirements.txtheroku login
heroku create <app-name>
heroku apps
git push heroku mainheroku config
heroku config:set KEY=value
heroku addons:create heroku-postgresql:essential-0
heroku run python manage.py <cmd>heroku logs --tail
heroku ps
heroku ps:scale web=1
heroku restartheroku releases
heroku rollback <version>heroku domains:add <domain>
heroku domains
heroku domains:remove <domain>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.
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.
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.
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.
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
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.
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.
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.
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.
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.