taught, not instructed

Heroku, for someone who has
only ever run VMs.

You already know how to keep a service alive on a machine you control — systemd, apt, nginx, a static IP, an SSH session at 2am. Heroku isn't a harder version of that. It's a different set of trade-offs, and once you see what it traded away and what it gave back, the commands stop being incantations.

The worked example throughout is your own app — not a toy.

Back to the main code::core guide

Repomakanika/AfricanGraphyteSprintTicketing
Branchclaude/heroku-deployment-setup-o8gnla
StackDjango 5 · Channels/Daphne · Postgres · Redis · Celery
Part One

The trade Heroku is actually making

Eleven ideas, each pinned against the VM instinct it replaces. Nothing here re-explains TCP, HTTP, or process management — you've done BGP and industrial control systems; that ground is solid. What's genuinely new is the platform abstraction sitting on top of it.


1.1

Pets vs. cattle — the one idea everything else follows from

A VM you run is a pet: it has a name, an uptime you're proud of, a history of patches and manual fixes, and you'd notice immediately if it vanished. A Heroku dyno is cattle: identical, disposable, and replaced wholesale — not patched, replaced — on every single deploy, and at minimum once every 24 hours regardless of whether you touched anything. Heroku does this on purpose, because a container that's always rebuilt from the same recipe can never drift from that recipe the way a hand-maintained VM eventually does.

The consequence that trips up almost every VM administrator first: nothing your app needs can live only inside the dyno. Not a config tweak you made by hand after deploy, not a file saved to local disk, not a cron job you added outside of code. If it isn't in git, in an environment variable, or in an attached service, it will be gone the next time Heroku throws the container away — which is routine, not an outage.

1.2

git push replaces your deploy script

You've deployed by rsync, scp, or an Ansible playbook copying files onto a box you can name. Heroku collapses that entire pipeline into one command because it added itself as a second git remote the moment the app was created (heroku create does this silently). git push heroku <branch>:main is simultaneously the file transfer and the trigger — receiving the push is what kicks off everything in 1.3.

on your VMsrsync moves bytes; a separate step (systemctl restart, an Ansible handler) makes them live, and a third step (you, watching logs) confirms it worked. On Heroku those three steps are one push — which is convenient, but also means the build has to fully succeed before anything changes, by design (more in 1.11).
1.3

The buildpack replaces the provisioning script you'd normally write once and rerun

On a fresh VM you'd install Python, pin its version, create a virtualenv, install dependencies, and probably write that down as an Ansible role or a shell script so it's repeatable. Heroku's buildpack does the equivalent automatically on every push: it reads requirements.txt to know what to install and .python-version to know which interpreter, and produces a fresh, correct environment every time — you never SSH in to run apt install or pip install by hand again, because "by hand" isn't a concept dynos support (1.1).

1.4

Procfile is your systemd unit files, declared in one place

Your stack today almost certainly runs several long-lived processes side by side under systemd or Docker Compose — the Django app itself, a Celery worker consuming a queue, maybe a beat scheduler. Each one is its own unit file or container, started and supervised independently. The Procfile is Heroku's version of that same idea, just declared as plain text instead of a directory of unit files — one line per process type, and each process type gets scaled and restarted independently of the others, exactly like separate systemd services would be.

on your VMsWhere you'd write /etc/systemd/system/celery-worker.service and celery-beat.service as separate unit files and enable each, Heroku reads that same intent off two lines in one Procfile.
1.5

The dyno's local disk is not your disk — it's scratch space

This is the sharpest edge for a VM administrator, so it earns its own idea rather than being folded into 1.1. On a VM, /var/www/media/ or wherever you write uploaded files is exactly as permanent as the VM itself — it survives reboots, deploys, everything short of you deleting it. A dyno's local filesystem survives nothing: it's wiped and rebuilt from scratch on every deploy and on Heroku's routine 24-hour dyno cycling. Anything your code writes to disk at runtime — user-uploaded attachments, generated reports, anything not baked in at build time — is gone the moment that happens.

This isn't a bug to work around with clever caching; it's the direct consequence of 1.1's disposability guarantee. The fix is architectural, not procedural: files that need to persist go to a service that lives outside the dyno's lifecycle entirely — an S3-compatible bucket — the same way your database already has to (1.6).

1.6

Add-ons: rented services addressed by URL, not packages you apt-installed

You'd normally apt install postgresql, apt install redis-server, and either self-host or apt-install a message broker, all living on disk on your VM or a VM next to it, configured by you, patched by you, backed up by you. A Heroku add-on is the same category of service, but running as a separate managed resource you attach rather than install — Heroku Postgres, Heroku Redis, and (for a message broker outside Heroku's own catalog) a third party like CloudAMQP.

The mechanical difference that matters day to day: attaching an add-on doesn't hand you a server to configure — it hands your app a connection string, injected automatically as a config var (1.7) the moment it's attached. You point your code at DATABASE_URL the way you'd point it at localhost:5432 today; what's on the other end of that string is now Heroku's operational problem, not yours.

1.7

Config vars are /etc/environment, but versioned and encrypted per app

Same underlying idea as anything you'd export in /etc/environment or a systemd EnvironmentFile — values the running process reads at startup rather than values baked into a config file that's sitting in git. Heroku calls them config vars, stores them encrypted, scopes them per app, and — usefully — bundles a change to them with a new release the same way a code push is (1.11), so you can see exactly when a variable changed relative to your deploy history.

1.8

Heroku's router is your nginx, minus the config file — and minus the control

You'd normally hand-write an nginx or haproxy config to terminate TLS, set headers, and reverse-proxy to your app's socket. Heroku's router does the equivalent job — TLS termination, routing incoming requests to a healthy dyno — without a config file to write, because there isn't one to write. The trade is real: you gain zero-maintenance routing and lose the fine-grained control an nginx config gives you. For websocket traffic specifically (1.9), the router does correctly forward the connection upgrade — worth confirming explicitly the first time, since it's exactly the kind of thing a hand-rolled reverse proxy sometimes gets wrong and needs a specific directive for.

1.9

Why this particular app needs Daphne instead of the usual gunicorn — WSGI vs. ASGI

Most Django deployment guides tell you to run gunicorn. This app's web process runs Daphne instead, against config.asgi:application rather than the more common wsgi:application — and that's deliberate, not a leftover. gunicorn's default model handles one request, sends one response, done — WSGI, a synchronous request/response cycle. Your ticketing app needs tickets to update live on screen without a page reload, which means holding a websocket connection open indefinitely per connected client — a fundamentally different shape of traffic than "receive, process, respond, close." ASGI (what Django Channels and Daphne speak) is built for exactly that: long-lived, bidirectional connections alongside ordinary HTTP requests, in the same app.

on your VMsClosest analogue: this is the same category of decision as choosing an event-driven proxy over a one-connection-per-thread model when you're expecting thousands of long-held sockets rather than short request bursts — you've made calls like this before, just not phrased as "WSGI vs ASGI."
1.10

heroku run is your SSH session — scoped to one command, then gone

heroku run bash starts a fresh, temporary dyno — built from the same release currently live, with the same config vars — drops you into a shell, and destroys that dyno the moment you exit. It's not a way back into the dyno serving live traffic (there isn't one, deliberately, per 1.1); it's a disposable sandbox with identical code and config, which is normally exactly what you want for running a management command or poking at the environment without risking the process actually serving users.

1.11

A release is atomic — it either fully replaces the old version or doesn't happen at all

An rsync deploy can, in principle, leave a VM in a half-updated state if it's interrupted midway. Heroku's release process is built to avoid that category of failure entirely: the new build has to succeed completely, then the release phase (typically your migration) has to succeed completely, and only then does traffic actually switch to the new version — if either step fails, the previous release keeps serving requests, untouched. You get a numbered release history (heroku releases) and can roll back to any of them instantly, which is a stronger guarantee than most hand-rolled VM deploy scripts give you by default.

— end of Part One —

Everything from here is your actual repository, read through these eleven ideas rather than a generic tutorial app.


Part Two

What's already sitting in your repo, and why

Before provisioning anything, it's worth reading the files that are already committed on claude/heroku-deployment-setup-o8gnla as artifacts of the ideas above — not as boilerplate to trust blindly.

01

Procfile — your systemd units, in one file (1.4)

Four process types. release is special — Heroku runs it once, automatically, before switching traffic to the new version (1.11), which is where the migration belongs.

Procfile
release: python manage.py migrate
web:     daphne -b 0.0.0.0 -p $PORT config.asgi:application
worker:  celery -A config worker --loglevel=info
beat:    celery -A config beat --loglevel=info
02

.python-version — your provisioning pin, without a role to write (1.3)

This is the entire input the buildpack needs to know which interpreter to build against — the equivalent of a version pin you'd otherwise encode in an Ansible role or a Dockerfile's FROM line.

.python-version
3.12
03

config/settings.py — where 1.6 and 1.7 actually land in code

Already written to prefer Heroku's injected connection strings when they exist, and fall back to your Docker-Compose values otherwise — meaning the identical settings file runs correctly on your VM/Compose setup today and on Heroku tomorrow, which is the same "read from environment, not hardcoded" discipline as 1.7, just already done for you.

config/settings.py — the shape of it
import os
import dj_database_url

DATABASE_URL = os.environ.get("DATABASE_URL")     # set automatically once Postgres is attached (1.6)
REDIS_URL = os.environ.get("REDIS_URL")           # same, once Heroku Redis is attached
CLOUDAMQP_URL = os.environ.get("CLOUDAMQP_URL")   # same, once CloudAMQP is attached

# falls back to your Docker-Compose defaults when these are absent —
# the same file runs locally and on Heroku unchanged

ALLOWED_HOSTS += [".herokuapp.com"]        # auto-allows the Heroku host
# also patches Heroku Redis's self-signed TLS, since Heroku Redis
# terminates with a cert your local redis-server never presented
None of this needs editing to get a first deploy live — it's shown here so the provisioning steps in Part Three read as "attaching the thing this file is already waiting for," not as unexplained platform magic.
Part Three

Provisioning: renting what you'd normally apt-install

Straight application of 1.6 — three add-ons standing in for db, redis, and rabbitmq in your docker-compose.yml.

01

Create the app, then attach each service

shell
$ heroku login
$ heroku create your-app-name

$ heroku addons:create heroku-postgresql:mini
$ heroku addons:create heroku-redis:mini
$ heroku addons:create cloudamqp:lemur          # Celery broker — free plan
Add-onReplaces (1.6)Injects (1.7)
Heroku Postgresdb containerDATABASE_URL
Heroku Redisredis containerREDIS_URL
CloudAMQPrabbitmq containerCLOUDAMQP_URL
simplify furtherCelery can use Redis as its own broker instead of RabbitMQ — one fewer service to reason about, at the cost of RabbitMQ's sturdier queue guarantees. If you'd rather do that, skip cloudamqp:lemur and set CELERY_BROKER_URL to the same value as REDIS_URL in Part Four.
Part Four

Configuring, then the push itself

1.7's config vars, then 1.2 and 1.11's atomic push — in that order, since the app needs its secret key and hosts set before it will boot cleanly.

01

Set what only you can know

Skip DATABASE_URL, REDIS_URL, and CLOUDAMQP_URL — Part Three's add-ons already set those.

shell
$ heroku config:set DJANGO_SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(50))")
$ heroku config:set DJANGO_DEBUG=0
$ heroku config:set DJANGO_ALLOWED_HOSTS=your-app-name.herokuapp.com
$ heroku config:set DJANGO_CSRF_TRUSTED_ORIGINS=https://your-app-name.herokuapp.com

$ heroku config:set EMAIL_HOST=smtp.yourprovider.com \
                   [email protected] \
                   EMAIL_HOST_PASSWORD=your-smtp-password \
                   DEFAULT_FROM_EMAIL="Sprint NOC "
02

Push

This single line is 1.2 and 1.3 firing in sequence: transport, build, then — per 1.11 — release: python manage.py migrate runs and must succeed before traffic ever reaches the new code.

shell
$ git push heroku claude/heroku-deployment-setup-o8gnla:main
!
Watch the build log scroll past — dependency install, an automatic collectstatic, then the release-phase migration, all before the swap. On a VM this was three separate manual steps you'd run and verify individually; here it's one push, and it refuses to go live if any stage fails (1.11).
Part Five

Seeding data, and turning on the other process types

5.1

Seed via a one-off dyno (1.10)

shell
$ heroku run python manage.py seed_teams
$ heroku run python manage.py seed_reference_data
$ heroku run python manage.py createsuperuser
5.2

Scale on the worker and beat process types (1.4)

A fresh app only runs web — the equivalent of only having enabled one of your several systemd units. The queue and scheduler need to be switched on explicitly, each as its own dyno.

shell
$ heroku ps:scale web=1 worker=1 beat=1
ProcessRunsDoes
webdaphne … config.asgi:applicationHTTP + websockets, bound to $PORT — 1.9
workercelery -A config worker …intake, notify, sla queues
beatcelery -A config beatschedules the per-minute SLA sweep, mailbox poll, shift digest

Each extra dyno is billed separately — check current pricing before scaling up. For light NOC traffic, one of each on the smallest paid tier is plenty to start.

5.3

Verify

Part Six

Reading the error codes as VM failures in disguise

Heroku's crash codes look cryptic exactly once. Each one is a familiar VM-era failure, just surfaced through a narrower vocabulary because there's no shell open on the box for you to look around in.

H10 — App crashed

On a VM you'd call it: the service failed to start — the same category of failure as a botched systemctl start.

Check: heroku logs --tail right after the deploy — the actual Python traceback is in there, same as it would be in journalctl -u.

H12 — Request timeout (30s)

On a VM you'd call it: a reverse-proxy upstream timeout — nginx giving up on a slow backend.

Check: a view or query running too long; the router's timeout is fixed and not configurable the way an nginx proxy_read_timeout would be.

H14 — No web dynos running

On a VM you'd call it: the service is stopped.

Check: heroku ps:scale web=1 — someone (possibly you, testing something) scaled it to zero.

R14 — Memory quota exceeded

On a VM you'd call it: OOM-killed.

Check: lower Celery's --concurrency, or move that process type to a larger dyno size.

Static files 404

On a VM you'd call it: an nginx alias misconfigured, pointing at the wrong path.

Check: heroku run python manage.py collectstatic --noinput — confirm it actually ran during the build.

Websocket won't connect

On a VM you'd call it: the proxy isn't forwarding the Upgrade header — a classic hand-rolled-nginx mistake, and the direct payoff of naming 1.8 and 1.9 explicitly above.

Check: DJANGO_ALLOWED_HOSTS and the CSRF trusted origins include your actual Heroku host.

For anything not on this list, heroku run bash (1.10) is your familiar SSH-and-poke-around — just temporary, on a fresh dyno built from the exact release that's live.

Part Seven

Shipping features all month while the team is live

This is the plane-and-mechanic problem: the app is 5% built, your team is already using it, and you'll be pushing changes for weeks with real ticket data underneath them. The good news arrives before any of the precautions — it's worth understanding why it's true, not just trusting it.

7.1

Your data already outlives the dyno — this part isn't new work

Reread 1.1 and 1.6: Postgres isn't inside the container that gets rebuilt on every push — it's a separate, persistent service your app connects to. A deploy replaces code. It does not touch the database sitting behind DATABASE_URL. You could push fifty times this month and the tickets your team enters today are still there on push fifty-one, untouched by any of them. The mechanic-on-the-wing feeling is mostly about the two things below — not about the plane itself falling apart.

7.2

Migration discipline — the one real way code touches existing data

A migration that adds something is safe by construction — new column, new table, nothing existing changes shape. A migration that removes or renames something is where data actually gets touched. The rule for a month of continuous iteration: never combine the two in one deploy.

ChangeDo it in one deploy?Why
Add a field, a model, a tableYesAdditive — nothing existing is affected
Rename a fieldNo — two deploysAdd the new one, backfill, deploy; drop the old one in a later deploy once you've confirmed the new one is populated
Drop a column or tableNo — capture a backup first (7.4)Irreversible the moment it runs — the safety net is the backup, not the migration itself
7.3

The one genuinely urgent item — 1.5, restated with a deadline

!
This is live risk today, not a month from now: if the team is uploading attachments right now, anything written to MEDIA_ROOT disappears on your very next push — or on Heroku's routine 24-hour dyno cycle, whichever comes first, whether or not that push touches media code at all.

Move it to S3-backed storage (django-storages, MEDIA_URL pointed at a bucket) before anything else on this list — everything else here is a precaution, this one is closer to a ticking clock. Static assets are unaffected; Whitenoise already serves those from the build itself, not from runtime disk.

7.4

A backup is seconds away — use it before any risky migration

shell
$ heroku pg:backups:capture                       # on-demand snapshot, right before a risky migration
$ heroku pg:backups:schedule --at '02:00 Africa/Kampala'   # automatic daily, so you're never more than a day from a restore point
$ heroku pg:backups                                # list what you have
$ heroku pg:backups:restore <backup-id> DATABASE_URL  # the actual undo, if it's ever needed

Capture one manually right before anything in the "no" row of 7.2's table. Schedule the daily one once, now, and forget about it.

7.5

A second, cheap app — so a bad migration never meets live data at all

The strongest version of "flying the plane while the mechanic works" is giving the mechanic a second plane to practice on first. A staging app is a few dollars a month and removes the guesswork entirely — a risky migration runs there, gets watched, and only reaches your team's app once it's already proven safe.

shell — one-time setup
$ heroku create your-app-name-staging
$ heroku addons:create heroku-postgresql:mini --app your-app-name-staging
$ heroku addons:create heroku-redis:mini --app your-app-name-staging
$ git push heroku main --app your-app-name-staging     # same code, separate app, separate database

# from here on, a risky change goes to staging first —
# then, once it's confirmed, to the app your team actually uses
the merge, onceEverything above assumes you're deploying from main. Bring the deployment-readiness commits over once, and every push for the rest of the month is a plain git push heroku main — no branch juggling on top of everything else.
shell
$ git checkout main
$ git merge claude/heroku-deployment-setup-o8gnla
$ git push heroku main
The month, in one line: additive migrations freely, destructive ones only after a backup (or on staging first), and media storage moved off the dyno before it becomes a real loss instead of a theoretical one.
Reference

Config vars and commands, at a glance

VariableSourceYou set it?
DATABASE_URLHeroku Postgres add-onno — automatic
REDIS_URLHeroku Redis add-onno — automatic
CLOUDAMQP_URLCloudAMQP add-onno — automatic
DJANGO_SECRET_KEYyouyes — Part Four
DJANGO_DEBUGyouyes — set to 0
DJANGO_ALLOWED_HOSTSyouyes, or rely on the automatic *.herokuapp.com allow
EMAIL_*youyes, if intake/notifications need to send mail

Everyday commands

shell
$ heroku logs --tail                 # follow logs, all processes
$ heroku ps                          # what's running, and its state
$ heroku run bash                    # one-off dyno, poke around (1.10)
$ heroku config                      # list all config vars
$ heroku restart                     # restart every dyno
$ heroku releases                    # deploy history (1.11)
$ heroku rollback                    # back to the previous release, instantly
$ git push heroku HEAD:main          # deploy again from any local branch
Once web, worker, and beat all show up in heroku ps and login works, the deploy is live. The one follow-up worth scheduling deliberately — not urgently — is S3-backed media storage, per the caveat above.