Walk into any independent auto workshop in South Africa and you’ll see the same thing: a dog-eared notebook, sticky notes on the monitor, WhatsApp messages as a job tracking system, and invoices typed up in Word. TorqueBooks exists because a workshop management system should be simple, multi-tenant, South African, and free to try.
What You’ll Learn
-
Multi-Tenant Architecture
How shop_id scoping isolates data at the database level.
-
PocketBase RLS Gotchas
Real bugs and workarounds from 8 weeks of development.
-
OAuth2 Integration
Keycloak SSO with case-sensitive field names and silent failures.
-
Schema-as-Code
Why manual UI configuration doesn’t scale.
The Problem
Walk into any independent auto workshop in South Africa and you’ll see the same thing: a dog-eared notebook, sticky notes on the monitor, WhatsApp messages as a job tracking system, and invoices typed up in Word. Customers don’t know when their car was last serviced. Mechanics don’t know what work is pending. Shop owners don’t know if they’re profitable.
The existing solutions? Over-engineered, US-centric, enterprise-priced garbage that requires a sales call, a demo, and a credit check before you can even see if it works.
TorqueBooks exists because a workshop management system should be:
- Simple enough that a mechanic with grease on his hands can use it
- Multi-tenant — one instance serves many shops, each sees only their own data
- South African — VAT, ZAR currency, local service intervals, WhatsApp nudges
- Free to try — no sales call, no credit card, just a demo button
What It Looks Like
Before we dive into the build story, here’s what the finished product looks like. Swipe through the carousel to see dashboard, job cards, customer management, and invoices.
The Architecture
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ React SPA │────→│ PocketBase │────→│ SQLite │
│ (nginx) │ │ (API + Auth)│ │ (embedded) │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ Keycloak │ │ Cloudflare │
│ (SSO) │ │ (CDN/SSL) │
└─────────────┘ └──────────────┘
Why PocketBase?
It’s a single binary that bundles a REST API, authentication, file storage, and a SQLite database. No Kubernetes. No microservices. No ORM. One container, one volume, one backup file. For a workshop with 5 mechanics and 200 customers, it’ll never break a sweat.
Why Keycloak?
Workshop owners don’t want to manage yet another set of login credentials. Keycloak provides OAuth2 SSO so they can use their existing NemesisNet identity. Mechanics get invited via token links — no signup form, no friction.
Why React + nginx?
Static files served by nginx with runtime config injection. No SSR complexity. No Node.js in production. The frontend talks directly to PocketBase — no Express backend, no REST middleware, no GraphQL. Just fetch() calls.
The War Story: 8 Weeks of Development
Week 1: The Prototype
The first version took one weekend. React + Vite, PocketBase with a few collections, basic CRUD. It was ugly. It worked.
Week 2: Multi-Tenant Realization
Without multi-tenancy, every workshop needs their own instance. That means separate databases, separate backups, separate domains. Multi-tenancy with shop_id scoping means one instance, one backup, one domain. Every record gets a shop_id. Every API query filters by @request.auth.shop_id. Every RLS rule enforces it.
@request.auth.record.field didn’t work for user tokens. Upgrading to 0.38.2 fixed it — but introduced a new syntax: @request.auth.field (no .record.).
Week 3: The RLS Nightmare
Row-Level Security in PocketBase is powerful but opaque. Rules that look correct silently return empty results. The debug cycle is: write a rule → test → get zero records → stare at it → realize the syntax is wrong → fix → test → works.
We went through this for every collection. Twice.
Week 4: The OAuth2 Abyss
Configuring PocketBase’s OAuth2 provider for Keycloak should be straightforward. It wasn’t.
Lessons Learned
- Field names are case-sensitive:
authURL,tokenURL,userInfoURL(uppercase URL), notauthUrl. Using lowercase silently fails. clientSecrethasomitempty: Omitting it from a PATCH request overwrites the stored secret with an empty string. Always include it.mappedFields.namemust be set: An emptymappedFieldscauses OAuth2 user creation to fail with “name: cannot be blank” — a generic 400 with no helpful error.createRulemust be empty: PB 0.38.2’s OAuth2 record creation has no auth context. Theuserscollection’screateRulemust be"", or signup fails with “Failed to create record.”
Week 5: The sort=-created Bug
About halfway through, sorting by -created started returning 400 errors on every collection. The error message? “Something went wrong while processing your request.” Thanks, PocketBase.
After a day of debugging, the fix was trivial: use sort=-id instead. PB IDs encode the creation timestamp, so sorting by ID descending gives the same result.
Week 6: The Duplicate Records Mystery
Jobs were showing “Unassigned” for the mechanic name even when a mechanic was assigned. The root cause: PB’s PATCH response doesn’t include expand data. After updating a job, the frontend showed stale data until the next page load.
Lesson: Always Re-fetch After Update
Always re-fetch the record with expand after every update. A simple lesson, but one that caused hours of head-scratching.
Week 7: The Invite System
The join-by-invite flow was conceptually simple but had a subtle RLS trap: unauthenticated users need to read invites by token. The invites collection’s listRule and viewRule must be "" (public). Any restriction blocks the join page from fetching the invite.
Week 8: Production Deployment
Production PocketBase was already running with test data. The problem: no schema management. Collections were created manually through the admin UI, relations were added by trial and error, RLS rules were copy-pasted and often wrong.
The Fix: Schema-as-Code
scripts/setup-pb.mjs became the single source of truth. It creates all 9 collections with explicit autodate fields, 14 relation fields, the Keycloak OIDC provider, and RLS rules for every collection. Running it against a fresh PB instance takes 30 seconds.
node scripts/setup-pb.mjs && node scripts/seed-demo.mjs
The Schema: 9 Collections
| Collection | Type | Purpose |
|---|---|---|
shops |
base | Workshop profiles — name, address, VAT, invoice prefix |
users |
auth | Staff accounts — role-based access (superadmin > shop_owner > clerk > mechanic) |
customers |
base | Vehicle owners — name, phone, email, notes |
vehicles |
base | Customer vehicles — make, model, registration, VIN, service intervals |
jobs |
base | Job cards — status lifecycle (pending → in_progress → completed → invoiced) |
job_parts |
base | Line items on jobs — cascading delete with parent job |
invoices |
base | Billing — draft/sent/paid lifecycle with due dates and overdue detection |
invites |
base | Workshop join tokens — email + role + 32-char token |
waitlist |
base | Pre-launch signups — name, email, workshop name, plan |
Every record is scoped to a shop_id. Every API query filters by it. Every RLS rule enforces it. Multi-tenant isolation at the database level.
The Tech Stack
| Layer | Choice | Why |
|---|---|---|
| Frontend | React 19 + Vite + Tailwind 4 | Familiar, fast builds, utility-first CSS |
| Backend | PocketBase 0.38.2 | Single binary, embedded SQLite, REST API, file storage |
| Auth | Keycloak 26.1 | Centralized SSO, OIDC, no password management |
| @react-pdf/renderer | React components → PDF invoices | |
| State | Zustand 5 | Minimal boilerplate, no context wrapping |
| Forms | React Hook Form 7 | Performant forms with validation |
| Animations | Framer Motion 12 | Polish without bloat |
| Icons | Lucide React | Clean, consistent icon set |
| Container | Docker + nginx | Production builds under 2MB, runtime config injection |
The Demo
Visit torquebooks.nemesisnet.co.za and click “Try Demo” — or log in with:
- Shop owner:
[email protected]/demopass123 - Mechanic:
[email protected]/mechanic123
The demo includes a seeded workshop with 5 customers, 7 vehicles, 8 jobs spanning 3 months, 21 job parts across 5 jobs, and 3 invoices in various states (draft, sent, paid). Three vehicles are overdue for service — visible on the dashboard with months-since-service counts.
What’s Next
- WhatsApp/email service reminders — Automated nudges for overdue services
- Email delivery — SMTP integration for invoice sending
- Real-time subscriptions — Live dashboard updates via PB’s real-time API
- Parts inventory — Stock tracking with auto-decrement on job completion
Key Lessons Summary
- Multi-tenancy from day one — Add
shop_idto every collection before writing a single query. - Schema-as-code is essential — Manual UI configuration doesn’t scale. Script it.
- PocketBase RLS is opaque — Test every rule with every role. Silent failures are the norm.
- OAuth2 field names are case-sensitive —
authURLnotauthUrl. Go struct tags don’t forgive. - Always re-fetch after PATCH — PB’s PATCH response doesn’t include expand data.
Conclusion: Build for Your Market
TorqueBooks isn’t trying to compete with enterprise workshop management software. It’s trying to replace the notebook and the WhatsApp group with something that actually works — for South African workshops, at South African prices, with South African features like VAT and WhatsApp nudges.
The tech stack is deliberately simple: PocketBase, React, nginx, Keycloak. No microservices. No Kubernetes. No GraphQL. One container, one volume, one backup. For a workshop with 5 mechanics, that’s all you need.
If you’re building a SaaS for a specific market, start with the simplest possible stack and add complexity only when you need it. PocketBase got us from zero to production in 8 weeks — bugs and all.
Need Help Building Your SaaS?
We design and deploy multi-tenant applications for South African businesses. Whether you need a workshop management system, a booking platform, or a custom SaaS, our team can help you build something that actually works.
Related Reading
- Runtime Env Injection for Docker Frontends — The same pattern used in TorqueBooks
- ForkMyFolio Backend — Another multi-user platform built with Spring Boot
- SaaS Development Services — Scalable multi-tenant platforms with authentication and APIs



