Custom CRM SolutionsEliminating the SaaS Seat Tax: Cost Projections of Building vs. Renting for 250+ Users

Eliminating the SaaS Seat Tax: Cost Projections of Building vs. Renting for 250+ Users

Per-seat pricing compounds with headcount and annual escalators. Work through a five-year, assumption-explicit cost model of building a custom CRM versus renting one at 250+ users, with PostgreSQL row-level security, Kubernetes, and Keycloak reference architecture.

D

Danisur Rahman

Verified
Principal Distributed Systems Architect•Oct 9, 2026•13 min read
Eliminating the SaaS Seat Tax: Cost Projections of Building vs. Renting for 250+ Users

Per-seat pricing is a brilliant business model for the vendor and a quietly compounding liability for the buyer. Your CRM bill does not track the value you extract from it. It tracks headcount, and every new sales hire, support agent, customer-success manager, contractor, and executive "who just needs read access" adds a line item that renews annually, usually with an escalator attached.

Below roughly 50 seats, renting is almost always the right call. Past 250, the arithmetic changes. The vendor's marginal cost of your 251st user is close to zero, yet you pay the full list price for it. That gap is the seat tax.

This article gives senior engineers and technical buyers a defensible way to evaluate the alternative: building a custom CRM and business portal on commodity infrastructure. It includes a worked five-year cost model with every assumption stated, the architecture that keeps the build tractable, and the risks that make most "build" projects fail. All figures are illustrative and reflect typical ranges, not measurements from any specific company. Replace them with your own contract and payroll numbers before drawing conclusions.

Anatomy of the Seat Tax#

The headline per-seat price is rarely what you pay. The effective cost per user usually includes:

ComponentHow it inflates cost
Tier gatingSSO, audit logs, field-level security, and sandbox environments sit in the top tier
Add-on modulesForecasting, CPQ, conversation intelligence, and marketing automation are priced per user
Platform limitsAPI call quotas, storage overages, and custom-object caps trigger paid upgrades
Annual escalatorsContracts commonly embed uplifts of roughly 5 to 10 percent on renewal
Seat-class creep"Read-only" and "portal" users are cheaper but still metered
Integration taxMiddleware connectors (iPaaS) are priced per task or per connection
A useful metric is the fully-loaded seat cost: total annual vendor spend divided by active users. If you have never computed it, do so before reading further. Many teams discover it is 20 to 40 percent above the list price they remember.

Seat pricing also creates perverse behavior. Teams ration licenses, share logins (a security and compliance problem), and keep occasional users out of the system, so data lives in spreadsheets again. The tool that was meant to be the source of truth becomes the source of friction.

A Worked Example: Five-Year Projection#

Assumptions#

These are deliberately conservative and stated so you can challenge each one.

Rent scenario

  • Starting users: 250, growing 15 percent per year (250, 288, 331, 380, 437)
  • Fully-loaded seat cost: $190 per user per month in year 1 (list tier plus typical add-ons)
  • Annual price escalation: 7 percent

Build scenario

  • Year 1 build: 4.5 FTE-equivalent (engineers, a product manager, part-time design) at a 190k fully-loaded average, plus roughly 145k for data migration, QA, security review, and training
  • Existing SaaS stays live for 6 months during parallel run
  • Steady state: 2.5 FTE for maintenance and roadmap, with 3 percent annual raises
  • Infrastructure: Kubernetes, managed PostgreSQL, object storage, observability; about 30k in year 1, rising to roughly 66k by year 5
  • Compliance and security tooling (pen test, SIEM, backups): $40k per year, rising modestly
  • Residual third-party services (email delivery, telephony, enrichment data): 30k in year 1, about 60k to $70k afterward

The model#

python
400 font-semibold">from dataclasses 400 font-semibold">import dataclass

@dataclass
400 font-semibold">class Rent:
    users0: int = 250
    user_growth: float = 0.15
    seat_month: float = 190.0
    escalator: float = 0.07

    400 font-semibold">def cost(self, year: int) -> float:
        users = round(self.users0 * (1 + self.user_growth) ** year)
        price = self.seat_month * (1 + self.escalator) ** year
        400 font-semibold">return users * price * 12

@dataclass
400 font-semibold">class Build:
    400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic"># year 0 = first year
    capex: float = 855_000 + 145_000          400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic"># team + migration/QA/security
    overlap_saas: float = 285_000             400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic"># 6 months parallel run
    run_team: float = 475_000                 400 font-semibold">class=400 font-semibold">class="text-emerald-300">"text-slate-500 italic"># 2.5 FTE
    raise_pct: float = 0.03
    infra: tuple = (30_000, 50_000, 55_000, 60_000, 66_000)
    compliance: tuple = (0, 40_000, 42_000, 44_000, 46_000)
    residual: tuple = (30_000, 60_000, 63_000, 66_000, 70_000)

    400 font-semibold">def cost(self, year: int) -> float:
        400 font-semibold">if year == 0:
            400 font-semibold">return self.capex + self.overlap_saas + self.infra[0] + self.residual[0]
        team = self.run_team * (1 + self.raise_pct) ** (year - 1)
        400 font-semibold">return team + self.infra[year] + self.compliance[year] + self.residual[year]

rent, build = Rent(), Build()
cum_r = cum_b = 0
400 font-semibold">for y in range(5):
    r, b = rent.cost(y), build.cost(y)
    cum_r += r; cum_b += b
    print(f400 font-semibold">class="text-emerald-300">"Y{y+1}: rent={r:>10,.0f} build={b:>10,.0f} "
          f400 font-semibold">class="text-emerald-300">"cum_rent={cum_r:>10,.0f} cum_build={cum_b:>10,.0f}")

Results (illustrative)#

YearUsersRent (annual)Build (annual)Rent (cumulative)Build (cumulative)
1250570k1,345k570k1,345k
2288703k625k1,273k1,970k
3331864k649k2,137k2,619k
43801,061k674k3,198k3,293k
54371,306k701k4,504k3,994k
Read this honestly. Under these assumptions, building is more expensive for the first four years and cumulatively crosses over during year 5, ending about $510k (roughly 11 percent) ahead. That is not a dramatic headline, and it should not be. What matters more is the shape of the curves:

  • Rent grows with seats and escalators. Year 5 spend is more than double year 1.
  • Build run-rate is nearly flat. By year 5 the annual gap is about $605k, or 46 percent lower.
  • The build line is mostly fixed cost. Adding the 438th user costs perhaps a few hundred dollars per year in compute, while the rent line adds the full seat price.

If you plan beyond five years, or your headcount growth exceeds 15 percent, the picture improves rapidly. If you are shrinking or stagnant at 250 seats with modest add-ons, renting may genuinely win.

Sensitivity: what moves the crossover#

VariableDirectionEffect on break-even
Seat growthHigherEarlier crossover
Vendor escalatorHigherEarlier crossover
Build team sizeHigherLater crossover
Scope (fewer modules replaced)NarrowerEarlier, but smaller savings
Opportunity cost of engineersHigherLater; often omitted from models
Run a Monte Carlo over growth, escalator, and team cost before committing. Even a crude triangular distribution per variable shows whether your decision is robust or hinges on one optimistic input.

The Costs Spreadsheets Forget#

A fair comparison includes the things that hurt:

  1. Opportunity cost. 4.5 engineers building a CRM are not building your product. If your core business is not CRM, this is the largest hidden cost.
  2. Feature parity drag. Vendors ship hundreds of features yearly. You will not match them, and you should not try. Replace the 20 percent of functionality that 90 percent of your users touch.
  3. Key-person risk. A custom system understood by two people is a liability. Documentation, runbooks, and on-call rotation are line items.
  4. Security accountability. The vendor's SOC 2 report no longer covers you. You inherit patching, secrets management, and incident response.
  5. Ecosystem loss. Marketplace apps, certified consultants, and a hiring pool that already knows the tool.

The realistic middle path is often hybrid: keep best-of-breed commodity services (email delivery, telephony, e-signature) and own the system of record, workflow engine, and portals.

Reference Architecture for the Build#

Keep the design boring. A CRM is a relational, workflow-heavy, permission-heavy application, and PostgreSQL does that job well.

text
 Browser / Mobile
       |
   Ingress (TLS, WAF)
       |
 +-----+------------------------+
 |  API (REST + GraphQL)        |  <- stateless, horizontally scaled
 |  Auth: OIDC (Keycloak)       |
 +-----+------------+-----------+
       |            |
 PostgreSQL     Job queue (Postgres SKIP LOCKED / NATS)
 (RLS, audit)        |
       |        Workers: email sync, enrichment, webhooks
 Object storage (S3-compatible) - attachments
 OpenSearch (optional) - full-text and activity search

Resist microservices. A modular monolith with clear domain boundaries (accounts, contacts, deals, activities, billing, portal) is faster to build, and one team can operate it.

Data model with row-level security#

Permissions are where CRM projects bleed time. Pushing them into the database makes them uniformly enforced.

sql
400 font-semibold">CREATE 400 font-semibold">TABLE accounts (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id   uuid NOT NULL,
  owner_id    uuid NOT NULL,
  team_id     uuid NOT NULL,
  name        text NOT NULL,
  attrs       jsonb NOT NULL DEFAULT 400 font-semibold">class="text-emerald-300">'{}',
  created_at  timestamptz NOT NULL DEFAULT now(),
  updated_at  timestamptz NOT NULL DEFAULT now()
);

400 font-semibold">CREATE 400 font-semibold">INDEX accounts_team_idx ON accounts (tenant_id, team_id);
400 font-semibold">CREATE 400 font-semibold">INDEX accounts_attrs_gin ON accounts USING gin (attrs jsonb_path_ops);

400 font-semibold">ALTER 400 font-semibold">TABLE accounts ENABLE ROW LEVEL SECURITY;
400 font-semibold">ALTER 400 font-semibold">TABLE accounts FORCE ROW LEVEL SECURITY;

-- The API sets these per transaction: SET LOCAL app.user_id = 400 font-semibold">class="text-emerald-300">'...';
400 font-semibold">CREATE POLICY accounts_visibility ON accounts
  USING (
    tenant_id = current_setting(400 font-semibold">class="text-emerald-300">'app.tenant_id')::uuid
    AND (
      owner_id = current_setting(400 font-semibold">class="text-emerald-300">'app.user_id')::uuid
      OR team_id IN (
        400 font-semibold">SELECT team_id 400 font-semibold">FROM team_members
        400 font-semibold">WHERE user_id = current_setting(400 font-semibold">class="text-emerald-300">'app.user_id')::uuid
      )
      OR current_setting(400 font-semibold">class="text-emerald-300">'app.role', 400">true) = 400 font-semibold">class="text-emerald-300">'admin'
    )
  );

Add an append-only audit trail with a trigger. Most compliance questions reduce to "who changed this field and when".

sql
400 font-semibold">CREATE 400 font-semibold">TABLE audit_log (
  id         bigserial PRIMARY KEY,
  table_name text NOT NULL,
  row_id     uuid NOT NULL,
  actor_id   uuid,
  action     text NOT NULL,
  diff       jsonb NOT NULL,
  at         timestamptz NOT NULL DEFAULT now()
);

400 font-semibold">CREATE OR REPLACE FUNCTION audit_row() RETURNS trigger AS $
BEGIN
  400 font-semibold">INSERT INTO audit_log (table_name, row_id, actor_id, action, diff)
  VALUES (
    TG_TABLE_NAME,
    COALESCE(NEW.id, OLD.id),
    NULLIF(current_setting(400 font-semibold">class="text-emerald-300">'app.user_id', 400">true), 400 font-semibold">class="text-emerald-300">'')::uuid,
    TG_OP,
    jsonb_build_object(400 font-semibold">class="text-emerald-300">'old', to_jsonb(OLD), 400 font-semibold">class="text-emerald-300">'400 font-semibold">new', to_jsonb(NEW))
  );
  RETURN COALESCE(NEW, OLD);
END;
$ LANGUAGE plpgsql;

400 font-semibold">CREATE TRIGGER accounts_audit
AFTER 400 font-semibold">INSERT OR 400 font-semibold">UPDATE OR 400 font-semibold">DELETE ON accounts
FOR EACH ROW EXECUTE FUNCTION audit_row();

Why 250 users is a small workload#

Two hundred and fifty humans generate modest load: perhaps tens of requests per second at peak. A pair of API pods and a managed PostgreSQL instance with a standby replica is typically sufficient. The cost driver is people, not infrastructure, which is exactly why the cost curve flattens.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: crm-api
spec:
  replicas: 2
  selector:
    matchLabels: { app: crm-api }
  template:
    metadata:
      labels: { app: crm-api }
    spec:
      containers:
        - name: api
          image: registry.example.com/crm-api:1.0.0
          ports: [{ containerPort: 8080 }]
          envFrom:
            - secretRef: { name: crm-api-secrets }
          resources:
            requests: { cpu: 400 font-semibold">class="text-emerald-300">"250m", memory: 400 font-semibold">class="text-emerald-300">"512Mi" }
            limits:   { cpu: 400 font-semibold">class="text-emerald-300">"1",    memory: 400 font-semibold">class="text-emerald-300">"1Gi" }
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 5
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: crm-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: crm-api
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - 400 font-semibold">type: Resource
      resource:
        name: cpu
        target: { 400 font-semibold">type: Utilization, averageUtilization: 65 }

Identity: Replace Per-Seat SSO Upsells#

SSO and SCIM provisioning are often paywalled in higher tiers. Self-hosting an identity provider such as Keycloak removes that tax and gives you one identity plane for the CRM, the portals, and internal tools. A minimal realm client for the CRM front end:

json
{
  400 font-semibold">class="text-emerald-300">"clientId": 400 font-semibold">class="text-emerald-300">"crm-web",
  400 font-semibold">class="text-emerald-300">"protocol": 400 font-semibold">class="text-emerald-300">"openid-connect",
  400 font-semibold">class="text-emerald-300">"publicClient": 400">true,
  400 font-semibold">class="text-emerald-300">"standardFlowEnabled": 400">true,
  400 font-semibold">class="text-emerald-300">"attributes": {
    400 font-semibold">class="text-emerald-300">"pkce.code.challenge.method": 400 font-semibold">class="text-emerald-300">"S256",
    400 font-semibold">class="text-emerald-300">"post.logout.redirect.uris": 400 font-semibold">class="text-emerald-300">"https:400 font-semibold">class="text-slate-500 italic400 font-semibold">class="text-emerald-300">">//crm.example.com/*"
  },
  400 font-semibold">class="text-emerald-300">"redirectUris": [400 font-semibold">class="text-emerald-300">"https:400 font-semibold">class="text-slate-500 italic400 font-semibold">class="text-emerald-300">">//crm.example.com/callback"],
  400 font-semibold">class="text-emerald-300">"webOrigins": [400 font-semibold">class="text-emerald-300">"https:400 font-semibold">class="text-slate-500 italic400 font-semibold">class="text-emerald-300">">//crm.example.com"]
}

Map IdP group claims to the team_members table at login, and your RLS policies pick up the change immediately. External customers and partners can use the same system through a separate realm, so the portal does not need additional "portal licenses".

Migration Without a Big Bang#

The failure mode of in-house CRMs is a six-month build followed by a cutover nobody trusts. Reduce risk with an incremental strategy:

  1. Read replica first. Sync the SaaS data into PostgreSQL through its bulk and incremental APIs. Build reports and dashboards on it. This delivers value on day one and exposes data quality problems early.
  2. Portal next. Customer-facing portals and internal tools that the vendor charges extra for are low-risk to replace and highly visible.
  3. Workflow modules one at a time. Move lead capture, then opportunity management, then quoting. Each cutover is reversible because the sync still exists.
  4. Retire the license last. Keep a minimal read-only footprint through the contract term if needed for audit.

Make the load idempotent so it can be rerun safely:

sql
400 font-semibold">INSERT INTO accounts (id, tenant_id, owner_id, team_id, name, attrs, updated_at)
400 font-semibold">SELECT s.id, s.tenant_id, s.owner_id, s.team_id, s.name, s.attrs, s.updated_at
400 font-semibold">FROM staging_accounts s
ON CONFLICT (id) DO 400 font-semibold">UPDATE
SET name = EXCLUDED.name,
    attrs = EXCLUDED.attrs,
    owner_id = EXCLUDED.owner_id,
    updated_at = EXCLUDED.updated_at
400 font-semibold">WHERE accounts.updated_at < EXCLUDED.updated_at;

Back everything up from day one, and test restores monthly. A nightly logical backup is a baseline:

bash
pg_dump --format=custom --no-owner 400 font-semibold">class="text-emerald-300">"$DATABASE_URL" \
  --file=400 font-semibold">class="text-emerald-300">"crm-$(date +%F).dump"
pg_restore --list 400 font-semibold">class="text-emerald-300">"crm-$(date +%F).dump" > /dev/400">null && echo 400 font-semibold">class="text-emerald-300">"dump readable"

Decision Framework: Build, Rent, or Hybrid#

Build is a strong candidate when most of these hold:

  • You have 250 or more seats and a growth trajectory, or many low-frequency users paying full price.
  • Your sales or service process is genuinely differentiated and fights the vendor's data model.
  • You already operate production software, with on-call practices and a security program.
  • You pay for integrations, portals, or SSO tiers that would be near-free on your own platform.
  • Data residency, sovereignty, or ownership requirements are strict.

Rent remains the better choice when:

  • Your process is standard and the vendor's defaults fit well.
  • You lack engineering capacity, or CRM would compete with core product work.
  • Your headcount is flat or declining.
  • Time-to-value in the next two quarters matters more than five-year economics.

Negotiation is the third lever. A credible, costed build plan is also the strongest leverage in a renewal conversation. Many teams recover a meaningful share of the seat tax simply by demonstrating that they can leave, and some never need to.

Conclusion#

The seat tax is real, but it is not a reason to build by itself. The honest illustration above shows a custom CRM costing more for roughly four years, then pulling ahead as seat growth and price escalators compound against a nearly flat run-rate. The decision hinges on your growth, how differentiated your processes are, and whether you can staff a small product team without starving your core business.

If you do build, keep the scope narrow, lean on PostgreSQL and open identity standards, migrate incrementally, and model your own numbers with the assumptions made explicit. If you would like a second set of eyes on a build-versus-rent model, a data-migration plan, or a reference architecture for a custom CRM and business portal, the knetwork team offers architecture consultations. We will help you pressure-test the numbers, including the outcome where renting wins.

Frequently Asked Questions

Key questions answered regarding this architectural implementation.

Senior Systems Architecture Advisory
Available for Q4 Advisory & Audits

Planning a high-concurrency overhaul or critical migration?

Move from theoretical documentation to production verification. You talk directly with the principal architects who build, benchmark, and deploy these systems—never a business development rep or account manager.

Direct WhatsApp ChannelFastest

Chat with an Architect

Direct access during working hours. Outline your stack, latency constraints, or bottlenecks for rapid feedback.

Open WhatsApp Discussion
Formal Architecture Brief1-Day SLA

Request an Engineering Review

Share your specification, RFP, or architecture diagram. We reply with technical scope and feasibility.

Mutual NDA Protected1 Working Day Response100% IP & Code Ownership
Explore All Contact Channels
D

Danisur Rahman

Lead Author

Principal Distributed Systems Architect • KNetwork Systems

Request Technical Review

Principal architect specializing in enterprise distributed systems, edge caching, and hardware integration pipelines. Leads engineering audits, high-concurrency database optimizations, and zero-trust VPC deployments across high-growth ventures.

Distributed BackendsEvent StreamingPrivate RAGIoT Telemetry
The Engineering Dispatch

Enjoyed this technical breakdown?

Subscribe to receive new architectural guides, system teardowns, and engineering benchmarks directly in your inbox.