<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Production Python & Infrastructure — Ebube Mba]]></title><description><![CDATA[Engineering log by Ebube Mba, a Python Backend/Full-Stack Engineer and Systems Administrator. Writing deep-dives on scaling multi-tenant SaaS applications (1,80]]></description><link>https://ebubemba.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 10:06:12 GMT</lastBuildDate><atom:link href="https://ebubemba.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[From Bare-Metal Infrastructure to Multi-Tenant SaaS: Architecting a Django Platform for 1,800+ Users]]></title><description><![CDATA[Case study on scaling a multi-tenant SaaS in Python, Django, PostgreSQL, Celery, Redis, and Docker to 1,800+ active users on Linux cloud infrastructure.]]></description><link>https://ebubemba.hashnode.dev/architecting-multi-tenant-django-saas-1800-users</link><guid isPermaLink="true">https://ebubemba.hashnode.dev/architecting-multi-tenant-django-saas-1800-users</guid><category><![CDATA[Python]]></category><category><![CDATA[Django]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Docker]]></category><category><![CDATA[backend developments]]></category><category><![CDATA[System Design]]></category><dc:creator><![CDATA[Ebube Mba]]></dc:creator><pubDate>Sun, 18 Oct 2026 15:24:02 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad360eb2ba0c327cb88e1dd/6e0aa3d2-2433-4c7d-9e0a-ffc369a6729e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Many software engineers start their careers in clean, abstracted cloud environments where the network never drops, power never flickers, and users always follow the happy path.</p>
<p>My path into backend engineering started closer to the metal. Before architecting multi-tenant web applications in <strong>Python and Django</strong>, I spent years in systems administration and IT infrastructure diagnosing LAN bottlenecks, hardening over 100 physical endpoints, and keeping local servers alive under real-world operational constraints.</p>
<p>When I set out to build <strong>EduSleek</strong>, a multi-tenant School Management SaaS platform now actively serving <strong>1,800+ teachers, administrators, and students</strong>, that infrastructure background fundamentally changed how I wrote software. Instead of building a fragile CRUD app, I designed every layer around a single question: <em>What happens when 500 users hit this endpoint at the exact same minute on an unstable connection?</em></p>
<p>Here is a look under the hood at the architectural decisions powering our production environment.</p>
<hr />
<h2>1. Solving the Multi-Tenant Schema Trap with PostgreSQL JSONB</h2>
<p>In enterprise software, especially in education, no two organizations measure data the exact same way.</p>
<p>One institution calculates termly grades using <code>CA1 (10%) + CA2 (10%) + Project (10%) + Exam (70%)</code>. Another uses four continuous assessments, practical lab scores, and completely different grade boundary rules.</p>
<p>If you hardcode assessment columns (<code>ca1_score</code>, <code>ca2_score</code>, <code>exam_score</code>) directly into a relational database table, onboarding a new client with a custom workflow requires altering schemas or writing messy conditional spaghetti code.</p>
<p>To keep the platform truly multi-tenant without per-client migrations, I engineered a <strong>dynamic JSON-based assessment blueprint engine</strong> in <strong>PostgreSQL</strong> and <strong>Django</strong>:</p>
<pre><code class="language-python"># Simplified view of how tenant blueprints validate dynamic score payloads
from decimal import Decimal
from django.core.exceptions import ValidationError

def validate_against_tenant_blueprint(blueprint_rules: list[dict], raw_scores: dict) -&gt; Decimal:
    total_score = Decimal("0.00")
    
    for rule in blueprint_rules:
        key = rule["key"]
        max_allowed = Decimal(str(rule["max_score"]))
        score = Decimal(str(raw_scores.get(key, 0)))

        if score &lt; Decimal("0.00") or score &gt; max_allowed:
            raise ValidationError(f"Invalid score for {rule['label']}: max allowed is {max_allowed}")
            
        total_score += score
        
    return total_score
</code></pre>
<p>Each tenant organization defines its own grading rubric via a validated JSON blueprint. At compilation time, the backend validates incoming assessment payloads against that tenant's specific schema and calculates weighted totals, rankings, and grade letters dynamically.</p>
<h2>2. Eliminating 502 Bad Gateways with Celery and Redis</h2>
<p>Traffic on an operational SaaS platform is rarely flat—it is violently spiky. During examination weeks and end-of-term result publication, hundreds of staff members simultaneously upload bulk CSV records, compile master broadsheets, and generate PDF report cards.</p>
<p>In early iterations, running heavy aggregation queries and PDF rendering synchronously inside the standard HTTP request-response cycle tied up Gunicorn workers, leading to request timeouts and 502 Bad Gateway errors under peak concurrency.</p>
<p>To decouple heavy I/O and compute from the web tier, I introduced an asynchronous task pipeline using Celery and Redis:</p>
<ol>
<li><p>Web Tier (Django + Gunicorn): Accepts the user's request, validates the payload, enqueues a task ID into Redis, and immediately returns a lightweight HTTP response in under 150ms.</p>
</li>
<li><p>Worker Tier (Celery): Background workers pull jobs from Redis to handle bulk user provisioning, complex broadsheet aggregations, and external API calls.</p>
</li>
<li><p>Reactive UI Polling (HTMX): Instead of pulling in a heavy Single-Page Application (SPA) frontend framework just to show a loading bar, the UI uses HTMX to poll lightweight status endpoints and swap in the completed results seamlessly once the Celery worker finishes.</p>
</li>
</ol>
<h2>3. Practical AI in Production: Async Report Remarks via the Groq LLM API</h2>
<p>At the end of every academic term, teachers face a massive administrative bottleneck: writing individualized, constructive performance remarks for hundreds of students.</p>
<p>Rather than adding "AI" as a gimmick, we integrated the Groq LLM API directly into our background task workflow. By feeding structured academic metrics i.e subject averages, strongest/weakest areas, and attendance trends into a deterministic prompt pipeline inside Celery, teachers can generate context-aware draft remarks in seconds, cutting end-of-term reporting time by over 50%.</p>
<pre><code class="language-python">@shared_task(bind=True, autoretry_for=(RequestException,), retry_backoff=True, max_retries=3)
def generate_student_remark_async(self, student_metrics: dict) -&gt; str:
    # Offloaded to Celery with exponential backoff to handle external API rate limits gracefully
    prompt = build_structured_academic_prompt(student_metrics)
    response = call_groq_completion_api(prompt)
    return response.strip()
</code></pre>
<h2>4. Designing for Offline Reality: Local State Caching for CBT</h2>
<p>Years of managing on-premise networks taught me a hard rule: you cannot rely on public internet uptime during high-stakes operations.</p>
<p>When engineering our Computer-Based Testing (CBT) suite for local campus deployments, I designed it to run completely independent of internet connectivity over a static-IP Local Area Network (LAN).</p>
<p>To protect students against sudden power outages or accidental browser closures mid-exam, the system pairs synchronized server-side timers with aggressive local state caching and client-side focus-loss detection. If a workstation loses power halfway through a 60-minute exam, the student can log back in on a backup terminal and resume from the exact second and question where they left off.</p>
<h2>5. Production Infrastructure &amp; Two-Layer Disaster Recovery</h2>
<p>We run our production stack fully containerized with Docker on Hetzner Cloud Linux infrastructure, orchestrated via Coolify behind reverse proxies (Traefik/Nginx).</p>
<p>Because our tenants trust us with institutional and financial ledger records, we enforce a two-layer disaster recovery protocol:</p>
<p>Infrastructure Layer: Automated off-instance VM snapshots on Hetzner with a rolling retention ring, enabling full bare-metal recovery in minutes if an OS or container upgrade ever goes sideways.</p>
<p>Database Layer: Scheduled logical pg_dump backups so we can perform granular, tenant-specific table restorations without ever rolling back the entire multi-tenant database.</p>
<h2>Final Thoughts</h2>
<p>Transitioning from hands-on systems administration into backend and full-stack software engineering gave me a perspective I wouldn't trade for anything: code doesn't live in a vacuum, it lives on servers, travels across imperfect networks, and serves real people who need 99.9% reliability.</p>
<p>In upcoming articles on this blog, I'll be sharing deeper technical breakdowns on our PostgreSQL indexing strategies, Docker deployment pipelines, and how we engineered an atomic double-entry fee reconciliation ledger in Django.</p>
]]></content:encoded></item></channel></rss>