Repository navigation
Deploy a Django app with Postgres on your own server (and fix the 4 errors everyone hits) #1243
thegdsks
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
By the end of this post you will have a Django app running on your own Linux box, talking to a managed Postgres 16, with migrations applied, secrets stored encrypted, health checks passing and one-command rollback. Along the way I hit the four errors that cost most Django developers an afternoon on a first container deploy: a process that dies in under a second, a refused
ALLOWED_HOSTS, a readiness probe that gets a 400, and a readiness probe that never gets a 200 because ofSECURE_SSL_REDIRECT. Each one gets its symptom, its cause and its fix.The commands use
levelrail-cliagainst a control plane you already have running. If you do not have one yet, start with Getting started and Installing.Contents
Pre-flight checklist
levelrail-clican reach itrequirements.txtlistsdjango,gunicorn,whitenoise,dj-database-urlandpsycopg[binary]config(adjustconfig.wsgibelow if it is not)How the pieces fit
flowchart LR A[git repo] -->|analyze + build| B[Levelrail control plane] B -->|image| C[Django container<br/>gunicorn + whitenoise] B -->|creates| D[(Postgres 16<br/>managed database)] D -->|DATABASE_URL injected| C E[Browser] -->|HTTPS| F[Caddy ingress] F -->|HTTP + X-Forwarded-Proto| C B -->|readiness probe<br/>Host: 127.0.0.1:PORT| CTwo details in that picture explain most of the pain. TLS ends at the ingress, so Django sees plain HTTP and has to be told to trust the forwarded protocol header. And the readiness probe talks to the container directly, by IP, so the
Hostheader it sends is127.0.0.1:<port>(the address it dials). Django rejects that host unless you plan for it.Make Django container friendly
A container gets its configuration from environment variables, so settings should read them and fail loudly when something is missing. Everything below is a baseline you can paste into a fresh
startprojectlayout.A few choices worth explaining:
SECURE_PROXY_SSL_HEADERis only safe behind a proxy you control. The Django settings reference says the proxy must strip the header from incoming requests and set it only for requests that arrived over HTTPS. The Levelrail ingress sits in that position, so the setting is safe here.SECURE_REDIRECT_EXEMPTpatterns must not start with a slash. Django's docs saySecurityMiddlewarestrips leading slashes from the path before matching, so the pattern is^healthz/$, not^/healthz/$. Get this wrong and the exemption silently never matches.STORAGESreplaces the default dict, it does not merge. That is why thedefaultentry is spelled out. Thestaticfilesbackend is the one WhiteNoise recommends for compression and cache-busting filenames.A health endpoint
It checks the database, so a deploy that cannot reach Postgres never becomes ready. Keep it cheap: one
SELECT 1, no ORM models, no auth.Start command and static files
Bind to
0.0.0.0, not127.0.0.1. A process listening only on loopback inside the container is unreachable from the probe and the ingress. The${PORT:-8000}default keeps the command working when no port is injected.WhiteNoise serves files out of
STATIC_ROOT, so something has to fill it, and its docs are blunt about it: runpython manage.py collectstatic --noinputbefore the app starts. Put that in your build step or Dockerfile. If you useCompressedManifestStaticFilesStorageand skip it, any template that calls{% static %}raises at render time, which shows up as a 500 on the first page that uses CSS.Tip
In a Dockerfile,
collectstaticneeds settings to import, and ours demand a secret key. A throwaway on that one line works and never reaches the running container's environment:RUN DJANGO_SECRET_KEY=build DJANGO_DEBUG=1 python manage.py collectstatic --noinput.Import the repo
Analyze first. This changes nothing, it only shows what Levelrail inferred:
The result is a table of detections, each with the evidence behind it: stack, build type, port, health path, environment keys and databases. Levelrail reads manifest files such as
requirements.txtandpyproject.tomland knows Django, so expect a Railpack build for the Python (Django) provider; the importing apps page lists what it looks at. If a row is wrong, override it with--build-type,--dockerfileor--base-directoryand analyze again.When the table looks right, apply:
--applycreates the suggested databases, the app, the repository link and the first build in one go. Add--no-databaseif you would rather bring your own Postgres.Expect the first deploy to fail if your settings insist on a secret key, as ours do. You have not set the secrets yet, and that failure is error 1 below.
Postgres and DATABASE_URL
When the analysis finds a Postgres dependency,
--applycreates a managed database and attaches it to the app, with its connection string injected asDATABASE_URL. You never copy a password around. Levelrail's Postgres accepts TLS, and the injected URL carries?sslmode=require. That is the reason to feed it todj-database-urlinstead of a hand-rolled parser that drops query parameters: the library turns the query string into the rightOPTIONSentry for you.Check the database any time:
The database name in that command is whatever
--applycreated, so read it from the import output if yours differs. See Connecting apps to databases for naming and for attaching more than one.Secrets and migrations
Set the values the app refuses to boot without.
--applyrestarts the app so the new value reaches the running container:Values are encrypted at rest and never printed back;
apps secrets list shopshows key names only.DJANGO_ALLOWED_HOSTSis not really a secret, but setting it the same way keeps all configuration in one place. The127.0.0.1is deliberate, and error 3 explains why.Once the app is running, apply migrations inside the container so they run against the real database with the real environment:
apps execexits with the command's own exit code, so it is safe in scripts and CI.Note
Levelrail's deploying apps page says the Railpack Django build runs
python manage.py migrateon start. Running it by hand is still the way to see the output and to be sure. If you use your own Dockerfile, you own that step.Troubleshooting: the four errors
All four came up while deploying a real Django app end to end, in this order.
1. The container exits in about 700 ms
Symptom.
levelrail-cli apps status shopshowsExitedDuringReadiness.levelrail-cli apps diagnose shopreportsCRASHLOOP_GENERIC, which tells you nothing you did not already know.Cause. Python died while importing settings, before gunicorn bound a port. The reason is in the logs, not the diagnosis:
What the log shows
A traceback through
config/settings.pyprecedes that line. A process that dies in well under a second almost always failed at import time, which for Django means settings.Fix. Set the secret as shown above. When status says the container exited during readiness, read
apps logsfirst andapps diagnosesecond.2. "Set DJANGO_ALLOWED_HOSTS to explicit production hosts"
Symptom. You tried
DJANGO_ALLOWED_HOSTS=*to get past the next error, and the app now refuses to start with that message.Cause. The settings above reject a wildcard when
DEBUGis off, on purpose. Per the Django docs,'*'matches anything and leaves you responsible for validating theHostheader yourself, and that validation is what protects password reset links from host header poisoning.Fix. Use an explicit comma separated list. It is the only form that passes the check:
3. Readiness probe returns 400
Symptom. The container stays up, but status shows a message of the form
GET http://127.0.0.1:<port>/healthz/ returned 400, expected 200-299.Cause. The probe dials the container by IP, so the request carries
Host: 127.0.0.1:<port>. WithDEBUGoff, that host is not inALLOWED_HOSTS,get_host()raisesDisallowedHost, and Django answers 400. WithDEBUGon and an emptyALLOWED_HOSTS, Django validates against.localhost,127.0.0.1and[::1]instead, which is why it worked on your laptop. Any platform that probes by IP runs into this, not only this one.Fix, option A. Put
127.0.0.1inDJANGO_ALLOWED_HOSTS, as in the command above. Django matches hosts without the port. It costs you nothing, because a request that reaches the container's own loopback address is already inside the container.Fix, option B. Tell the probe which host to send, using your real domain:
levelrail-cli apps health set shop --probe readiness --path /healthz/ --host shop.example.com levelrail-cli apps restart shopB keeps the allow list tight but ties the probe to a domain. A is quicker for a first deploy, B is tidier once the hostname is stable.
4. Readiness never goes green because of a redirect
Symptom. Hosts are right, yet readiness keeps failing against
/or any path you did not exempt.Cause.
SECURE_SSL_REDIRECT = Truemakes Django answer every plain HTTP request with a 301 to HTTPS. The probe is plain HTTP and sends noX-Forwarded-Proto, so it gets redirected. By default the probe follows redirects, which sends it to anhttps://URL on a port that only speaks HTTP, so the attempt fails. With redirects turned off you getreturned 301 to ...; set follow_redirects or expected_statusinstead. Either way it is the same cause.Fix. Point the probe at the path you exempted with
SECURE_REDIRECT_EXEMPT, restart, and check:levelrail-cli apps health set shop --probe readiness --path /healthz/ levelrail-cli apps restart shop levelrail-cli apps status shopIn my run, status went to
Ready Truewith reasonAlreadyRunningafter this change.Important
apps health setchanges the probe you name. Setting readiness does not touch liveness, so set both if you want both on/healthz/.levelrail-cli apps health discover shoptries well known paths and shows what each returns, which is a quick way to confirm the exempt path really answers 200.Logs, rollback, domains
Logs. Search stored entries, or follow live:
Rollback. Each deploy keeps the previous image pinned, so garbage collection cannot remove your way back. Roll back to an older, already-built image:
levelrail-cli apps images shoplists the tags you can pick from. A rollback does not undo migrations. Write them to be backward compatible for one release (add columns as nullable, drop them a deploy later) and rollback stays safe. Details in Rollback.Domains. Point a DNS record at the server, then attach it:
--waitprints each go-live step and exits non-zero if the domain is not live in time. Certificates are issued automatically. Make sure the domain is also inDJANGO_ALLOWED_HOSTSand, with its scheme, inDJANGO_CSRF_TRUSTED_ORIGINS, or login forms fail with a CSRF error. More in Domains and ingress.Quick reference
apps logsapps logsapps status127.0.0.1or set probe--hostapps statusDJANGO_CSRF_TRUSTED_ORIGINSNext steps
/healthz/withapps health set shop --probe liveness --path /healthz/If this saved you an afternoon, a star on the repo helps other Django developers find it. If you hit a fifth error, drop it in the comments with your
apps statusandapps logsoutput and I will add it to the table.Django references used above: SECURE_PROXY_SSL_HEADER, SECURE_REDIRECT_EXEMPT and WhiteNoise with Django.
All reactions