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.
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.
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.
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 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).
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.
/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.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).
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.
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.
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.
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.
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.
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.
Everything from here is your actual repository, read through these eleven ideas rather than a generic tutorial app.
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.
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.
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=infoThis 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.
3.12Already 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.
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 presentedStraight application of 1.6 — three add-ons standing in for db, redis, and rabbitmq in your docker-compose.yml.
$ 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-on | Replaces (1.6) | Injects (1.7) |
|---|---|---|
| Heroku Postgres | db container | DATABASE_URL |
| Heroku Redis | redis container | REDIS_URL |
| CloudAMQP | rabbitmq container | CLOUDAMQP_URL |
cloudamqp:lemur and set CELERY_BROKER_URL to the same value as REDIS_URL in Part Four.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.
Skip DATABASE_URL, REDIS_URL, and CLOUDAMQP_URL — Part Three's add-ons already set those.
$ 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_HOST_USER=noc@yourdomain.com \
EMAIL_HOST_PASSWORD=your-smtp-password \
DEFAULT_FROM_EMAIL="Sprint NOC " 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.
$ git push heroku claude/heroku-deployment-setup-o8gnla:maincollectstatic, 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).$ heroku run python manage.py seed_teams
$ heroku run python manage.py seed_reference_data
$ heroku run python manage.py createsuperuser
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.
$ heroku ps:scale web=1 worker=1 beat=1
| Process | Runs | Does |
|---|---|---|
web | daphne … config.asgi:application | HTTP + websockets, bound to $PORT — 1.9 |
worker | celery -A config worker … | intake, notify, sla queues |
beat | celery -A config beat | schedules 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.
heroku open — lands on /accounts/login/heroku logs --tail while you click around — your docker compose logs -f, per 1.10's spirit of a temporary window into a live systemheroku ps — confirm web, worker, and beat all show upHeroku'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.
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.
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.
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.
On a VM you'd call it: OOM-killed.
Check: lower Celery's --concurrency, or move that process type to a larger dyno size.
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.
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.
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.
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.
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.
| Change | Do it in one deploy? | Why |
|---|---|---|
| Add a field, a model, a table | Yes | Additive — nothing existing is affected |
| Rename a field | No — two deploys | Add 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 table | No — capture a backup first (7.4) | Irreversible the moment it runs — the safety net is the backup, not the migration itself |
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.
$ 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 neededCapture one manually right before anything in the "no" row of 7.2's table. Schedule the daily one once, now, and forget about it.
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.
$ 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 usesmain. 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.
$ git checkout main
$ git merge claude/heroku-deployment-setup-o8gnla
$ git push heroku main| Variable | Source | You set it? |
|---|---|---|
DATABASE_URL | Heroku Postgres add-on | no — automatic |
REDIS_URL | Heroku Redis add-on | no — automatic |
CLOUDAMQP_URL | CloudAMQP add-on | no — automatic |
DJANGO_SECRET_KEY | you | yes — Part Four |
DJANGO_DEBUG | you | yes — set to 0 |
DJANGO_ALLOWED_HOSTS | you | yes, or rely on the automatic *.herokuapp.com allow |
EMAIL_* | you | yes, if intake/notifications need to send mail |
$ 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 branchweb, 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.