
InsureCRM — a multi-tenant policy CRM running in production on AWS
A US insurance back office needed to run policies, customers, renewals and compliance from one role-aware system. I built and now operate a Laravel + React CRM on AWS ECS — including the production-stability and performance work that keeps it fast and reliable.
- Role
- Full-stack & DevOps engineer
- Timeline
- 2025–2026
- Stack
- Laravel · React · TypeScript · PostgreSQL · Redis · AWS ECS · Terraform
The problem
A US insurance back office was running its book of business — customer policies, renewals, appointments and agent activity — across tools that didn't talk to each other. They needed a single, role-aware system that several teams could use side by side, with a complete, auditable history of every change for compliance.
The work wasn't just to build it: it had to run reliably in production under real daily use, which is where most of the engineering ended up.
Architecture
The system is split into a Laravel REST API and a React (TypeScript) SPA, talking over HTTP. PostgreSQL is the system of record; Redis holds sessions, cache and queues. The whole thing is containerised and runs on AWS ECS Fargate behind an Application Load Balancer, with RDS for Postgres, ElastiCache for Redis, S3 for documents, and Secrets Manager for configuration. Infrastructure is described in Terraform and shipped through GitHub Actions.
The decisions that mattered:
| Decision | Chose | Why |
|---|---|---|
| Tenancy | Shared schema + tenant scope + RBAC | Simple to operate, safe by default |
| State | Sessions/cache in Redis, not the container | Stateless tasks, safe to scale and replace |
| Deploys | SHA-pinned task defs, blue/green via ALB | Roll forward and back with confidence |
| Audit | Permanent change history + access log | Compliance and traceability |
The build
Tenant isolation is enforced at the framework level so a forgotten where
clause can never leak another tenant's data:
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
if ($tenantId = auth()->user()?->tenant_id) {
$query->where($query->getModel()->getTable().'.tenant_id', $tenantId);
}
});
static::creating(fn ($m) => $m->tenant_id ??= auth()->user()?->tenant_id);
}
}On top of that sits a role-and-permission layer (admin / manager / agent) checked on the server for every action, plus two distinct audit trails: a permanent change history of customer/policy edits for compliance, and a separate access log of logins, permission denials and sensitive page views.
Running it in production
This is where the real work was. After launch, ECS tasks were being killed under load — traced to a PHP-FPM worker limit and sessions living on ephemeral containers. I tuned FPM, moved sessions and cache to Redis, and added a blue/green deploy with a pre-flight validation gate and tolerant health checks, so a bad release can't take the product down.
Then I went after latency. The dashboard was firing a dozen API calls on every mount and one notifications query was taking ~11 seconds on millions of audit rows. I bundled the startup calls, added Redis caching and trigram indexes, and deduplicated queries on the frontend:
I also found and removed a duplicate audit-write path that was doubling the access-log growth rate, and added a safe retention policy (chunked, batched deletes inside transactions, with snapshots) so the audit tables stay bounded.
Outcome
InsureCRM runs in production as the team's day-to-day system: policies, customers, renewals and appointments in one role-aware place, with a complete audit trail and a stable, fast experience under real load. Discovery, the Laravel API, the React dashboard, and the AWS/Terraform infrastructure were delivered and are operated by one developer — the client coordinates a single person across the whole stack.
Reflection
The lesson that stuck: shipping the features was the easy half — keeping it fast and up in production is the half that earns trust. Enforcing tenant isolation in the framework and pushing state into Redis early made everything afterwards safer to scale. Next time I'd put the latency budgets and load-testing in from day one rather than discovering them under real traffic.
Want a result like this?
Tell me what you’re building. I reply within one business day.