EduQate
2026 — ongoingMy day job at JoyPix, Doha · live at eduqate.qa
This is my day job. JoyPix's applications platform, built end to end from a short visual pitch. Organisations receive and review applications from the public: an applicant side, an operator side, and a platform-admin tier over both. Three web apps and a mobile app, nineteen domain modules over sixteen core concerns, five layers with every dependency pointing one way. It's live at eduqate.qa.
Role — Sole developer at JoyPix — architecture, backend, every client app, infrastructure, security
- Domain modules
- 19
- Client apps
- 4
- Architectural layers
- 5
- Services in the stack
- 10
- Automated tests
- 7,000+
- Problem write-ups
- 12
The constraint that shaped it
No second developer. Every architectural call was mine, with nobody to bounce it off. That's why error monitoring, schema-drift checks and targeted regression tests became load-bearing instead of nice-to-have.
The architecture
How it's laid out
Five layers, one dependency direction: clients, shared packages, domain modules, core concerns, infrastructure. Every arrow points down.
Four client apps share a small set of packages — types, an API client, query hooks, and two UI kits that import nothing else internal. Below them are nineteen domain modules (~21k lines) over sixteen core concerns (~11k lines): auth, policy resolution, settings, a transactional outbox, storage. Then Postgres, Redis, object storage and a Rust image service.
The point isn't tidiness. It's that I can read the blast radius of a change straight off the diagram — touch a shared package and every flow below it counts as unverified until I've driven it again.
Two exceptions, found by counting imports instead of trusting the diagram. The outbox dispatcher reaches up into notification modules to route events, and auth reaches up for the user record. Both are fixable with an interface owned by the lower layer, and neither has cost me anything yet.
The decisions worth asking about
A transactional outbox instead of calling side effects inline. Submitting an application owes the user a confirmation email and a notification. Calling those inside the same request that writes to the database leaves a real gap: if the email API has a bad moment right after the commit, the confirmation is gone and nothing says so. Instead I write the event in the same transaction as the change, and a separate process delivers it afterwards, retrying on failure.
A settings hierarchy that's really a safety override. Every configurable behaviour has a platform default, an organisation can override it, and individual branches can override it again. On the surface that reads as flexibility. The real reason is that a branch admin can make a genuinely harmful call, and someone with more authority above them needs to be able to correct it.
Session invalidation for tokens that are stateless by design. A version counter on each user, baked into every token when it's issued and checked on every request. Forcing a full logout is one increment — every token that user holds stops working, and there's no blacklist to maintain.
A form builder, and an argument about who holds the pen. Every organisation needs its own application form, and the open question was who builds it. The instinct was to let admins configure their own. I pushed back: staff aren't necessarily technical, and a live form is exactly where a small mistake becomes a real problem mid-cycle with no support team on standby. Form authoring stays platform-side with a preview-and-confirm step, and forms are versioned so every submission is locked to the version it was filled against.
Schema drift caught in CI. A check that diffs the schema definition against the applied migrations on every run and fails the moment they disagree, instead of letting the gap turn up later as a confusing bug.
Running it, not just building it
The half of this job that isn't in the codebase. I own the environment it runs in as much as the code.
One command brings the whole stack up locally — database, connection pooler, Redis, the Rust image service, the API, three web apps, a reverse proxy — with every dependency gated on a real health check, so the API can't boot against a database that isn't ready yet. Dev containers are name-prefixed away from production, after a CI failure I traced back to the runner sharing a Docker daemon with the live stack: a same-named compose up was colliding with running production containers, not leftovers from a cancelled run.
Production runs on three isolated Docker networks — public web, edge, data — so the database is only reachable from the one service that bridges to it. Nothing is published to the host. The only way in is a tunnel that dials outward, so there are no inbound ports to attack, and it runs as two replicas for failover.
The edge proxy default-denies any host it doesn't recognise, caps request body size and slow-client timeouts before a request reaches the app, and serves a branded maintenance page for the few seconds the backend is gone during a container swap.
The backend runs as a process cluster, not a single process — the runtime pins one process per CPU core. Everything clustering would otherwise break — rate-limit counters, socket rooms, OAuth exchange codes, background-job claims — moved to Redis first, and the boot refuses to start in production without it, so it can't quietly degrade to per-process behaviour.
CI/CD, and what “green” has to actually mean
Every workflow runs on self-hosted runners and declares a least-privilege permission block. Deploys aren't a CI job: a script on the server builds the images there, applies migrations, waits for the backend health check, scans every service's logs, and emails me the result either way.
The pipeline that gates a build: lint, typecheck, a migration-drift check that fails the moment the schema file and the applied migrations disagree, a smoke job that runs the whole stack *through* the connection pooler to catch the bug class that once corrupted data, dependency audits, CodeQL, and a Trivy scan for vulnerable packages and leaked secrets.
I stopped trusting “green” here twice. A cross-file workflow chain silently never fired, because that kind of trigger only evaluates from the default branch. No error anywhere; the run was simply never attempted. I collapsed the chain into one file, where job-to-job dependencies carry no such caveat. Separately, a whole CI run once passed every check while nothing had actually been built.
Backups I've actually restored, and a deploy runbook
The database is dumped nightly, shipped offsite and pruned on a retention window — and a separate job restores the latest backup into a throwaway container every week and checks the tables come back non-empty. A backup script exiting zero is not the same claim as a restorable backup.
Two deploy paths, both written down. A routine one, where additive-only migrations are safe with old code running through the few seconds of a swap. And a breaking-schema one that raises a maintenance flag first — gated so it can't be toggled casually — runs the migration behind it, and lifts it only once a log scan across every service comes back clean.
The scars are in the runbook too. Never run a volume prune on a box where the database volume could be momentarily unattached. Give production and monitoring their own compose project names, so a teardown on the wrong file can't reach the wrong volume. New indexes on tables that will hold real data go in their own migration, built concurrently, because a plain index build locks the table for its whole duration.
The security surface, set once and enforced everywhere
One global guard chain — throttle, authenticate, check role, check scope — in a fixed order, deny-by-default at every layer. A route that declares no role is closed. A route that declares a scope still has to re-derive and re-check ownership inside the service, because a request header can outrank the URL parameter and the guard on its own isn't enough.
Passwords: a slow hash, a breach-list check on every path that can set one, per-account lockout rather than per-IP, and a dummy hash on the no-such-user path so response timing can't enumerate accounts.
Tokens: stateless JWTs with a per-user version counter for instant revocation, refresh rotation with reuse detection that kills the whole session on a replayed token, refresh held in an httpOnly cookie and the access token in memory only — never local storage.
Rate limiting keys on the authenticated user, not the IP. Keying on IP means everyone behind one office network shares a single budget, which looks exactly like an attack. That needed the auth step to run before the limiter, which is deliberate ordering rather than an accident.
How it was built
April 2026 — now
JoyPix — sole developer on the platform
JoyPix's multi-tenant applications platform, built end to end alone: three web apps, a mobile app, infrastructure, security. Live at eduqate.qa.
Problems that came out of this project54
- A microservice that ran while nothing used it2026-08-31The service generates 200px and 800px variants on every upload and stores them in documents.thumbnails_json. Every organisation-facing resolver — resolveLogoUrl, resolveCoverUrl, gallery preview, full gallery, staff photos — signed document
- An SVG gradient id collision2026-08-31Every card rendered its scrim as an inline react-native-svg gradient using the same element id. In a list those collide across items and resolve to the wrong definition, so cards rendered a flat opaque grey box instead of a scrim. Swapped t
- Mobile throwing away the document type on upload2026-08-27Building the Documents Center, the plan was a document-type filter. The taxonomy already existed end to end — program_document_requirements seeded into document_requests on application creation, with a working attach endpoint already open t
- CI that kept running out of memory2026-08-23The self-hosted runner has finite RAM and was running rust, CodeQL and Trivy at once. Took two passes: serialize the heavy jobs, cap CodeQL and Trivy memory, one thread for CodeQL, exclude node_modules and build dirs — and then add 8GB of s
- Maintenance mode that in-app browsers walked past2026-08-23With maintenance mode on, a normal browser showed the maintenance screen and an embedded browser (Messenger, Instagram) rendered the normal app with no data.
- Three apps with no mobile layout, one with no nav2026-08-20The complaint was that the app was "very messy" on a phone. Measured at 390px:
- Two sticky elements on the same pixel2026-08-20At phone width the organisation detail page's section-jump nav (sticky top-0, z-30) pinned to the same pixel as the app header (sticky top-0, z-20) and painted over it, hiding the logo, the language switcher and half the Create Account butt
- 2FA that bounced people back to login2026-08-19Right after moving super-admin auth to env vars with Redis-backed email 2FA, completing the OTP step landed you back at login. The CSRF cookie was host-only and the second leg of the exchange is served from a different subdomain, so the tok
- An IDOR on tenant settings2026-08-19Straightforward IDOR — the ownership check every other organisation-scoped route applies wasn't applied on settings, so passing another organisation's id was enough. The more useful half of the fix was centralizing the ownership checks rath
- An Arabic label clipped by an English width2026-08-18The mobile signup progress indicator had stepLabel: { maxWidth: 64 } with numberOfLines={1}. "Personal info" just fits. "المعلومات الشخصية" doesn't, and React Native Web's truncation at that width doesn't add an ellipsis, it hard-clips — so
Related notes
- pgBouncer, and the search_path bug that corrupted data
- Revoking a token that's stateless by design
- Row-level multi-tenancy, and the one bug that matters
- The transactional outbox
- A backup you haven't restored is a hypothesis
- A Rust service for the one CPU-bound job
- A testing kit that isn't tied to a stack
- A tunnel that dials out, so there are no inbound ports
- Building Qatar's PDPPL into a product, not around it
- Three container networks, so the database has one neighbour
- Two deploy paths, and knowing which one you're on
- When the CI runner is also the production box