Deployment

Personal Catalogue: Django & PostgreSQL

Chapter 9 · Deployment

Every chapter so far has run against a local dev server. This chapter moves the finished catalogue onto a real server — reusing the Debian/web-server groundwork this site's own Setting Up a Web Server on Debian and Securing Your Web Server courses already cover, rather than re-deriving general server setup here.

Settings Never Travel With the Code

Chapter 1's own settings.py has a real database password and a hardcoded SECRET_KEY sitting directly in it — fine for local development, but neither should ever be committed to version control the way the rest of the codebase is. A committed secret stays in a Git repository's own history forever, even once deleted in a later commit.

# Install a real environment-variable loader pip install python-decouple
catalogue_site/settings.py (updated)
from decouple import config SECRET_KEY = config('DJANGO_SECRET_KEY') DEBUG = config('DJANGO_DEBUG', default=False, cast=bool) ALLOWED_HOSTS = config('DJANGO_ALLOWED_HOSTS', default='').split(',') DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', 'NAME': config('DB_NAME'), 'USER': config('DB_USER'), 'PASSWORD': config('DB_PASSWORD'), 'HOST': config('DB_HOST', default='localhost'), 'PORT': config('DB_PORT', default='5432'), } }

The real values live in a local .env file, added to .gitignore, with a placeholder-only .env.example committed in its place — the same real config/example split the PHP variant of this project (catalog-php1) uses for its own config.php/config.example.php pair:

# .env.example — committed, no real secrets DJANGO_SECRET_KEY=REPLACE_ME DJANGO_DEBUG=False DJANGO_ALLOWED_HOSTS=catalogue.example.com DB_NAME=catalogue DB_USER=REPLACE_ME DB_PASSWORD=REPLACE_ME DB_HOST=localhost DB_PORT=5432
DEBUG = True Is a Real Information Leak in Production
With DEBUG = True, an unhandled Django error shows the visitor a full, detailed traceback — real file paths, real settings values, and in a worse case, a database query. DEBUG defaults to False in the settings above specifically so a missing or misconfigured environment variable fails safe, rather than silently leaving debug output enabled on a real, publicly reachable server. Setting ALLOWED_HOSTS correctly also matters directly here — with DEBUG = False, Django refuses to serve any request whose Host header isn't in that list, a real protection against a class of HTTP Host header attacks.

Migrating the Schema to the Production Database

Rather than re-running every migration by hand and re-seeding data manually, the local database can be exported and imported directly:

# On the local machine, dump structure and data together pg_dump -U postgres catalogue > catalogue_export.sql # Copy it to the server scp catalogue_export.sql user@server:/tmp/ # On the server, create an empty database first... createdb -U postgres catalogue # ...then import the exported structure and data into it psql -U postgres catalogue < /tmp/catalogue_export.sql

Alternatively, for a genuinely fresh production database with no data to migrate yet, running python manage.py migrate directly against the production database (once .env points at it) recreates the real schema from Chapter 2's own migration files without needing a dump/restore step at all.

A Dedicated, Least-Privilege Database Role

Connecting as PostgreSQL's own postgres superuser role — even locally, as Chapter 1's example did — is a shortcut worth dropping before this goes anywhere real:

CREATE ROLE catalogue_app WITH LOGIN PASSWORD 'a-real-strong-password-here'; GRANT CONNECT ON DATABASE catalogue TO catalogue_app; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO catalogue_app;
Deliberately Not a Superuser
catalogue_app gets exactly what the running app actually needs — SELECT/INSERT/UPDATE/DELETE on the real tables the app queries — and nothing more. It deliberately can't DROP a table, alter the schema, or create new roles, so a bug or a successfully exploited vulnerability in the running app genuinely couldn't do that kind of damage, even in the worst case. DB_USER and DB_PASSWORD in the real production .env point at this role, never at postgres.

Static Files: Real Payoff of Chapter 8

Chapter 8 flagged that STATICFILES_DIRS only serves files in development. Production needs a real STATIC_ROOT plus collectstatic:

# settings.py STATIC_ROOT = BASE_DIR / 'staticfiles' # pip install whitenoise, then add to MIDDLEWARE (right after SecurityMiddleware) 'whitenoise.middleware.WhiteNoiseMiddleware',
python manage.py collectstatic --noinput

WhiteNoise lets Gunicorn itself serve static files efficiently, directly from STATIC_ROOT, without needing a fully separate nginx static-file configuration for a project this size — a real, deliberately lightweight choice matching the same small-project pragmatism this course has followed since Chapter 1.

Gunicorn as a Real Production WSGI Server

python manage.py runserver is explicitly documented as unsuitable for production. A real WSGI server runs the actual application instead:

pip install gunicorn gunicorn catalogue_site.wsgi:application --bind 127.0.0.1:8000

A real deployment runs this under a process manager (systemd is the natural choice, already covered by this site's own Setting Up a Web Server on Debian course), with nginx or Apache configured as a reverse proxy in front of it — genuine, general reverse-proxy setup this course doesn't re-derive, since it's already covered in full there.

Locking Down the Admin

Chapter 3's own warn-box flagged this directly: the admin shouldn't sit at its default, well-known URL on a real, publicly reachable deployment.

# catalogue_site/urls.py path(config('ADMIN_URL_PATH', default='admin/'), admin.site.urls),

Setting a real, non-obvious ADMIN_URL_PATH in production's own .env — something like a8x92-manage/ — doesn't replace real authentication as a security measure, but it does genuinely reduce exposure to the automated bots that specifically probe /admin/ on every Django site they find, since this project's own admin no longer sits at the address they're already looking for.

HTTPS Is Not Optional Here

This project's own admin and add-item form accept real form submissions, and real database credentials flow through the deployment process. Serving any of it over plain HTTP sends that traffic unencrypted. This site's own HTTPS/TLS Fundamentals course covers obtaining and configuring a certificate in full — a real prerequisite for this project going live, not an optional hardening step to add later.

A Simple, Real Backup Habit

# Add to crontab -e, runs daily at 2am 0 2 * * * pg_dump -U catalogue_app catalogue > /home/user/backups/catalogue_$(date +\%Y\%m\%d).sql
A Real, Honest Scope Note
A cron-scheduled pg_dump writing to the same server's own disk isn't a complete backup strategy — a real disk failure would take the backups down with the live data. For a small personal project, it's still a genuine, meaningful improvement over no backup at all, and copying the resulting .sql files somewhere off that same machine occasionally closes most of the realistic gap without needing a dedicated backup service.

Hands-On Exercises

Exercise 1

Create .env.example with placeholder values, add .env to a real .gitignore, and confirm (with git status, assuming the project is a Git repository) that .env genuinely doesn't appear as a trackable file once .gitignore is in place.

📄 View solution
Exercise 2

Create the catalogue_app PostgreSQL role with exactly the privileges shown in this chapter, then confirm — by trying and expecting it to fail — that a query like DROP TABLE catalogue_item; run as that role is correctly rejected with a real permissions error.

📄 View solution
Exercise 3

Set DEBUG=False and a deliberately incomplete ALLOWED_HOSTS in a local test setup, then explain the real error Django returns when visiting the site through a hostname not included in that list.

📄 View solution

Chapter 9 Quick Reference

  • .env / .env.example — real secrets gitignored, placeholders committed, loaded via python-decouple
  • DEBUG=False by default — fails safe if the environment variable is ever missing; paired with a real ALLOWED_HOSTS
  • catalogue_app roleSELECT/INSERT/UPDATE/DELETE only, never the app connecting as postgres
  • Static filesSTATIC_ROOT + collectstatic + WhiteNoise, the real production payoff of Chapter 8's own setup
  • Gunicorn — a real WSGI server replacing runserver, run behind a reverse proxy (nginx/Apache, per this site's own web-server courses)
  • Admin hardening — a non-default admin URL, resolving Chapter 3's own warn-box
  • Next chapter: the capstone — integrating this catalogue into the existing Astro site, and a real plan for barcode scanning as the next phase