DEV Community

Fundsroom Tech
Fundsroom Tech

Posted on

Running a Multi-Tenant Platform on EC2: Node.js, PostgreSQL, SES and the AWS Cost Bug We Almost Missed

description: Real architecture notes from FundsWeb and FundsAudit: Express, Sequelize, PM2, Nginx, SES, S3 and a cross-region data transfer bill that taught us to read AWS cost reports.
tags: aws, node, postgres, architecture
canonical_url: https://fundsroomprojects.com/blog/multi-tenant-platform-ec2-node-postgres

cover_image:

We build and run FundsWeb, a multi-tenant web platform, along with [FundsAudit] and an internal email validation service. This post covers the stack, the decisions we'd make again, and one AWS billing issue that cost us more than it should have.

The stack

Layer Choice
Runtime and API Node.js with Express
Database PostgreSQL, installed directly on EC2
ORM Sequelize
Process manager PM2
Reverse proxy Nginx
Email AWS SES
Storage S3
DNS Route 53
PDF and document generation Puppeteer

It is deliberately plain. For a small team, fewer moving parts means faster debugging.

1. Why EC2 with PM2 and Nginx

We run the app on EC2 instead of a managed container platform.

  • PM2 restarts crashed processes, manages logs and gives us zero-downtime reloads with pm2 reload.
  • Nginx terminates TLS, serves static files, and proxies to the Node process. Each tenant domain is a server block pointing to the same app.
  • Trade-off: we own patching, backups and scaling. Managed services remove that work. We chose control and predictable cost, and we accept the operational load.

2. Tenancy with Sequelize

Tenant context is resolved once per request and passed to the data layer, not rebuilt inside each controller.

  • Models include a tenant identifier, and scoped queries apply it centrally.
  • Sequelize scopes keep the filter in one place, so a developer can't forget it on a single route.
  • Migrations are versioned and run per environment.

[Add one real detail: how you resolve the tenant (subdomain, domain, token) and one bug this design prevented.]

3. Generating documents with Puppeteer

We use Puppeteer to render HTML templates into PDFs for [reports / audit documents, edit to match].

What we learned:

  • Treat the browser like any other heavy dependency: limit concurrency, set timeouts, and always close pages.
  • Render from the same HTML templates the UI uses, so layout stays consistent.
  • Memory spikes appear when many PDFs are queued at once, so queue them.

4. Email on SES, and building FundsMailer

Sending email through SES is easy. Keeping your sender reputation healthy is the hard part. Bounces and complaints affect delivery, so we built FundsMailer, an internal email validation service.

It checks addresses before sending, runs a set of validation checks, produces a score, and exposes the result through an API.

One limitation worth knowing: probing mail servers over SMTP from EC2 has constraints, because AWS restricts outbound port 25 by default. So SMTP-level checks can't be the only signal. [Add which checks you rely on instead: syntax, DNS/MX, disposable-domain lists, etc.]

5. The cross-region bill

This was the most useful lesson.

Our EC2 instance and SES were in us-east-1, but the S3 buckets used by the email pipeline were in ap-south-1. Every read and write crossed regions, and AWS charges for inter-region data transfer. The cost showed up in billing before it showed up in any monitoring.

What we did:

  1. Used Cost Explorer, grouped by usage type, to find the data transfer line items.
  2. Traced them to the services moving data between regions.
  3. Created a new bucket in us-east-1, next to SES and EC2, and migrated the pipeline to it.
  4. Repeated the same setup in our second AWS account.

[Add the real before/after cost figure if you're comfortable sharing it, or say "a significant share of the bill".]

Rule we follow now: keep compute, email and storage in the same region unless there's a reason not to, and check the cost report by usage type every month.

6. Don't leave a bucket public by accident

While auditing, we found a bucket serving images that was publicly writable or listable more than it needed to be. [Edit to match what you actually found.] We fixed it by turning on Block Public Access and serving public images through a controlled path.

Review your bucket policies and ACLs on a schedule. It takes ten minutes and prevents long incidents.

7. FundsAudit: what's different

[Write 4-6 real lines: what FundsAudit does, which part of the stack it leans on (for example Puppeteer reports, role-based access, audit trails), and one design decision you'd defend.]

What we'd tell a team starting today

  1. Keep the stack boring until a real constraint forces a change.
  2. Resolve tenant context in one place.
  3. Queue anything heavy, like PDFs or email sends.
  4. Keep compute, storage and email in one region.
  5. Read the AWS cost report by usage type every month.
  6. Audit bucket policies regularly.

We build custom ERP and SaaS products at Fundsroom. If you're solving similar problems, tell us in the comments what you'd do differently.

Top comments (0)