<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: ian otieno</title>
    <description>The latest articles on DEV Community by ian otieno (@ian_otieno_f614db0e641912).</description>
    <link>https://dev.to/ian_otieno_f614db0e641912</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4036167%2F0b58eefd-6a07-4eb9-a73f-1e1ae4c95555.jpg</url>
      <title>DEV Community: ian otieno</title>
      <link>https://dev.to/ian_otieno_f614db0e641912</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ian_otieno_f614db0e641912"/>
    <language>en</language>
    <item>
      <title>Building and Deploying a Role-Based Django PWA: From Starter Template to Production</title>
      <dc:creator>ian otieno</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:26:21 +0000</pubDate>
      <link>https://dev.to/ian_otieno_f614db0e641912/building-and-deploying-a-role-based-django-pwa-from-starter-template-to-production-513o</link>
      <guid>https://dev.to/ian_otieno_f614db0e641912/building-and-deploying-a-role-based-django-pwa-from-starter-template-to-production-513o</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live app:&lt;/strong&gt; &lt;a href="https://volunteer-board-nccd.onrender.com" rel="noopener noreferrer"&gt;https://volunteer-board-nccd.onrender.com&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Source code:&lt;/strong&gt; &lt;a href="https://github.com/13getid/django-starter" rel="noopener noreferrer"&gt;https://github.com/13getid/django-starter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Demo accounts if you want to poke around without signing up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organization: &lt;code&gt;demo.org@example.com&lt;/code&gt; / &lt;code&gt;DemoPass123!&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Volunteer: &lt;code&gt;demo.volunteer@example.com&lt;/code&gt; / &lt;code&gt;DemoPass123!&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;Rather than building a Django project from a blank &lt;code&gt;startproject&lt;/code&gt;, I started from a &lt;a href="https://github.com/achingachris/django-starter" rel="noopener noreferrer"&gt;SaaS Pegasus-derived starter template&lt;/a&gt; that already had a lot of the tedious groundwork done: &lt;code&gt;django-allauth&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I built on top of it
&lt;/h2&gt;

&lt;p&gt;The concept: a &lt;strong&gt;volunteer opportunity board&lt;/strong&gt;. Organizations post opportunities; volunteers browse and sign up.&lt;/p&gt;
&lt;h3&gt;
  
  
  Two roles, enforced for real
&lt;/h3&gt;

&lt;p&gt;I added a &lt;code&gt;role&lt;/code&gt; field to the custom user model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CustomUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AbstractUser&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextChoices&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ORGANIZATION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Organization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;VOLUNTEER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;volunteer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Volunteer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;VOLUNTEER&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@property&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_organization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ORGANIZATION&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part wasn't the field — it was hooking it into &lt;code&gt;django-allauth&lt;/code&gt;'s signup flow without fighting the library. Allauth's &lt;code&gt;SignupForm&lt;/code&gt; has a &lt;code&gt;custom_signup(request, user)&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TermsSignupForm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TurnstileSignupForm&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ChoiceField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CustomUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;widget&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RadioSelect&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;custom_signup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cleaned_data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Role isn't just cosmetic — every opportunity-related view checks it server-side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@login_required&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;opportunity_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_organization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;PermissionDenied&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Only organizations can post opportunities.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A volunteer account hitting &lt;code&gt;/opportunities/new/&lt;/code&gt; directly gets a real 403, not just a hidden button.&lt;/p&gt;

&lt;h3&gt;
  
  
  The opportunities app
&lt;/h3&gt;

&lt;p&gt;A standard Django app with two models — &lt;code&gt;Opportunity&lt;/code&gt; (title, description, location, date, cover image, spot count) and &lt;code&gt;SignUp&lt;/code&gt; (a through-model linking a volunteer to an opportunity, with a &lt;code&gt;unique_together&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Progressive Web App
&lt;/h3&gt;

&lt;p&gt;Three pieces, deliberately simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manifest&lt;/strong&gt; — the starter already shipped icon assets and a &lt;code&gt;site.webmanifest&lt;/code&gt;, it just needed &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;start_url&lt;/code&gt;, and &lt;code&gt;scope&lt;/code&gt; filled in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service worker&lt;/strong&gt;, served via Django's &lt;code&gt;TemplateView&lt;/code&gt; at the site root (&lt;code&gt;/sw.js&lt;/code&gt;) rather than through Vite's &lt;code&gt;/static/&lt;/code&gt; path — service workers need root scope to control the whole site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline fallback page&lt;/strong&gt; — the service worker's fetch handler falls back to a custom "You're offline" page on navigation failures.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;navigate&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OFFLINE_URL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deployment: where the real learning happened
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;after&lt;/em&gt; that surfaced one infrastructure lesson after another.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 1: free-tier disk is ephemeral
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# prod.py
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLOUDINARY_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;STORAGES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BACKEND&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloudinary_storage.storage.MediaCloudinaryStorage&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lesson 2: Docker Command quoting is fragile
&lt;/h3&gt;

&lt;p&gt;My first deploy attempt used a single chained shell command (&lt;code&gt;migrate &amp;amp;&amp;amp; collectstatic &amp;amp;&amp;amp; exec gunicorn ...&lt;/code&gt;) directly in Render's "Docker Command" field. It failed with &lt;code&gt;command not found&lt;/code&gt; — the platform's command-parsing didn't like the inline &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
python manage.py migrate &lt;span class="nt"&gt;--noinput&lt;/span&gt;
python manage.py collectstatic &lt;span class="nt"&gt;--noinput&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;gunicorn config.wsgi:application &lt;span class="nt"&gt;--bind&lt;/span&gt; 0.0.0.0:8000 &lt;span class="nt"&gt;--workers&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Lesson 3: outbound SMTP ports are blocked
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;socket.connect()&lt;/code&gt; never returning.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The fix: switch from SMTP to Brevo's &lt;strong&gt;HTTP API&lt;/strong&gt; instead, using &lt;code&gt;django-anymail&lt;/code&gt;'s Brevo backend, which the starter already had installed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;EMAIL_BACKEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anymail.backends.brevo.EmailBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;ANYMAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BREVO_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BREVO_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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."&lt;/p&gt;

&lt;h3&gt;
  
  
  Lesson 4: Gmail is suspicious of Gmail
&lt;/h3&gt;

&lt;p&gt;Even after switching to the API, one specific case still deferred: sending &lt;em&gt;from&lt;/em&gt; a &lt;code&gt;@gmail.com&lt;/code&gt; address (verified in Brevo) &lt;em&gt;to&lt;/em&gt; another &lt;code&gt;@gmail.com&lt;/code&gt; address. Brevo's delivery logs showed the exact reason:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;421-4.7.28 Gmail has detected an unusual rate of mail originating from your SPF...&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is now, and what's next
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about something: &lt;strong&gt;the UI you'll see on the live demo right now is a starting point, not a finished design.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 — &lt;strong&gt;I'd love to hear it.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thoughts
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you're working on something similar, the one thing I'd flag loudest: &lt;strong&gt;when something fails with no error message at all&lt;/strong&gt;, especially anything network-related, suspect the platform's outbound rules before you suspect your code.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Repo: &lt;a href="https://github.com/13getid/django-starter" rel="noopener noreferrer"&gt;https://github.com/13getid/django-starter&lt;/a&gt; — feel free to look through the commit history, it roughly tells this whole story in order. Issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
