Building and Deploying a Role-Based Django PWA: From Starter Template to Production

Building and Deploying a Role-Based Django PWA: From Starter Template to Production

I recently built a full volunteer-opportunity board on top of a Django starter template — complete with role-based accounts, image uploads, a Progressive Web App layer, and a real production deployment. This post walks through what I built, the decisions behind it, and — more usefully — the deployment problems I actually hit and how I solved them, since that's the part tutorials usually skip. Live app: https://volunteer-board-nccd.onrender.com Source code: https://github.com/13getid/django-starter Demo accounts if you want to poke around without signing up: Organization: demo.org@example.com / DemoPass123! Volunteer: demo.volunteer@example.com / DemoPass123! The starting point Rather than building a Django project from a blank startproject, I started from a SaaS Pegasus-derived starter template that already had a lot of the tedious groundwork done: django-allauth for authentication, a custom user model with an avatar field, Tailwind v4 + DaisyUI wired up through Vite, DRF for an API layer, and a production-ready Docker Compose stack. This turned out to be the right call. Registration, login, password change, and even a "change picture" avatar upload were already working the moment I ran the dev server. That meant I could spend my time on the actual product instead of re-implementing auth from scratch. What I built on top of it The concept: a volunteer opportunity board. Organizations post opportunities; volunteers browse and sign up. Two roles, enforced for real I added a role field to the custom user model: class CustomUser(AbstractUser): class Role(models.TextChoices): ORGANIZATION = "organization", "Organization" VOLUNTEER = "volunteer", "Volunteer" role = models.CharField(max_length=20, choices=Role.choices, default=Role.VOLUNTEER) @property def is_organization(self) -> bool: return self.role == self.Role.ORGANIZATION Enter fullscreen mode Exit fullscreen mode The interesting part wasn't the field — it was hooking it into django-allauth's signup flow without fighting the library. Allauth's SignupForm has a custom_signup(request, user) hook that runs automatically right after the user is created, which is exactly the right place to persist a field that isn't part of allauth's own form: class TermsSignupForm(TurnstileSignupForm): role = forms.ChoiceField(choices=CustomUser.Role.choices, widget=forms.RadioSelect) def custom_signup(self, request, user): user.role = self.cleaned_data["role"] user.save(update_fields=["role"]) Enter fullscreen mode Exit fullscreen mode Role isn't just cosmetic — every opportunity-related view checks it server-side: @login_required def opportunity_create(request): if not request.user.is_organization: raise PermissionDenied("Only organizations can post opportunities.") Enter fullscreen mode Exit fullscreen mode A volunteer account hitting /opportunities/new/ directly gets a real 403, not just a hidden button. The opportunities app A standard Django app with two models — Opportunity (title, description, location, date, cover image, spot count) and SignUp (a through-model linking a volunteer to an opportunity, with a unique_together constraint so you can't double-sign-up). List view supports search and location filtering; detail view has a sign-up/cancel toggle; and each role gets its own dashboard. Progressive Web App Three pieces, deliberately simple: Manifest — the starter already shipped icon assets and a site.webmanifest, it just needed name, start_url, and scope filled in. Service worker, served via Django's TemplateView at the site root (/sw.js) rather than through Vite's /static/ path — service workers need root scope to control the whole site. Offline fallback page — the service worker's fetch handler falls back to a custom "You're offline" page on navigation failures. self.addEventListener("fetch", (event) => { if (event.request.mode === "navigate") { event.respondWith( fetch(event.request).catch(() => caches.match(OFFLINE_URL)) ); } }); Enter fullscreen mode Exit fullscreen mode Deployment: where the real learning happened I deployed to Render's free tier — no credit card required, real Postgres, real persistent web service. The Docker build itself went smoothly on the first attempt, which was almost suspicious in retrospect, because everything after that surfaced one infrastructure lesson after another. Lesson 1: free-tier disk is ephemeral The starter's production Docker Compose uses a named volume for media uploads. Render's free web service disk doesn't persist that way — anything written locally gets wiped on redeploy. Avatars and opportunity photos would silently vanish. Fix: route media storage through Cloudinary instead of local disk, gated behind an environment variable so local development still uses the filesystem: # prod.py if "CLOUDINARY_URL" in env: STORAGES["default"]["BACKEND"] = "cloudinary_storage.storage.MediaCloudinaryStorage" Enter fullscreen mode Exit fullscreen mode Lesson 2: Docker Command quoting is fragile My first deploy attempt used a single chained shell command (migrate && collectstatic && exec gunicorn ...) directly in Render's "Docker Command" field. It failed with command not found — the platform's command-parsing didn't like the inline && chaining. The fix was more reliable than debugging the quoting: move the whole thing into an actual shell script file committed to the repo, and point the Docker Command at that instead. #!/usr/bin/env bash set -e python manage.py migrate --noinput python manage.py collectstatic --noinput exec gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 3 Enter fullscreen mode Exit fullscreen mode Lesson 3: outbound SMTP ports are blocked This was the interesting one. I configured Brevo (a transactional email service) over SMTP, and the connection just... hung. No error, no timeout message — the request would eventually die when Gunicorn's worker timeout killed it. The traceback pointed straight at socket.connect() never returning. It turns out many free-tier PaaS providers block outbound SMTP ports (25, and often 587 too) as a spam-prevention measure. This isn't documented prominently anywhere obvious — I found it by elimination, after confirming the credentials, the sender verification, and the Django settings were all correct, and the connection was still just hanging. The fix: switch from SMTP to Brevo's HTTP API instead, using django-anymail's Brevo backend, which the starter already had installed: EMAIL_BACKEND = "anymail.backends.brevo.EmailBackend" ANYMAIL = {"BREVO_API_KEY": env("BREVO_API_KEY", default="")} Enter fullscreen mode Exit fullscreen mode HTTP traffic over port 443 isn't blocked the way SMTP is, and the switch fixed it immediately — email sending went from "hangs forever" to "sub-second response." Lesson 4: Gmail is suspicious of Gmail Even after switching to the API, one specific case still deferred: sending from a @gmail.com address (verified in Brevo) to another @gmail.com address. Brevo's delivery logs showed the exact reason: 421-4.7.28 Gmail has detected an unusual rate of mail originating from your SPF... This is Gmail's own anti-spoofing protection — it's inherently wary of mail claiming to be "from Gmail" that didn't actually come through Google's infrastructure, since that's a common phishing pattern. It's a soft, temporary deferral rather than a hard bounce, and email to non-Gmail addresses delivers cleanly. The durable fix is authenticating a real custom domain as the sender rather than relying on a free Gmail address — outside the scope of what a demo project needs, but worth documenting honestly rather than pretending it doesn't exist. What I'd do differently If I were starting this project without a deadline, I'd set up the custom email domain from the start rather than retrofitting it, and I'd reach for Render's paid tier's Shell access earlier — debugging blindly through commit-push-check-logs cycles for the SMTP issue cost more time than a five-minute interactive shell session would have. Where this is now, and what's next I want to be upfront about something: the UI you'll see on the live demo right now is a starting point, not a finished design. I've reworked the landing page into a dark themed layout, redesigned the auth pages, and added stat-card dashboards. Mobile responsiveness was actually broken partway through — the nav bar was overflowing on small screens — and I've since fixed and confirmed it works properly on an actual phone. But there's real design work still ahead: pushing the dark theme more consistently across every page, tightening spacing and color balance, and generally giving the whole thing a more considered visual pass than what a first build gets. I'm treating this as a living project rather than a one-and-done submission. Design and UX are iterative, and I'd genuinely rather ship something functional now and keep refining it in the open than sit on it chasing pixel-perfection before anyone sees it. If you look at this and have thoughts — a layout you'd approach differently, a color palette that would work better, an accessibility issue I've missed, a feature that would make the opportunity board more useful — I'd love to hear it. Suggestions are genuinely welcome, and so is collaboration — open an issue, leave a comment, or send a PR. This is exactly the kind of small project that benefits from more eyes on it. Closing thoughts Starting from a solid template made the auth/user/styling groundwork nearly free, which meant almost all my actual engineering time went into the parts that mattered: the role-permission model, the opportunity/sign-up feature, and — unexpectedly — a genuinely useful lesson in how free-tier cloud infrastructure quietly blocks things you'd never think to check until they silently fail. If you're working on something similar, the one thing I'd flag loudest: when something fails with no error message at all, especially anything network-related, suspect the platform's outbound rules before you suspect your code. Repo: https://github.com/13getid/django-starter — feel free to look through the commit history, it roughly tells this whole story in order. Issues and PRs welcome.

📰 Original Source

Read full article at Dev →

KhanList aggregates and links to publicly available news content. We do not host full articles from third-party sources. Always verify important information with original sources.