TL;DR — A 60% AWS reduction came from three fixes, none of which touched the architecture. Stop logging request and response bodies on every call — verbose logging was the single largest line item, inflating both CloudWatch ingestion and S3 storage. Add S3 lifecycle rules so logs and old uploads expire instead of accumulating forever at full-price storage class. Serve static files from NGINX, not Node.js — streaming files through the event loop burns EC2 CPU for work a web server does better, and offloading it cut CPU 40% and memory 35%, which is what made downsizing the instance safe. Total: $1,250/month to $480/month, with latency improving rather than regressing.
When I became the sole backend and infra owner of a food-tech SaaS, the AWS bill was higher than it needed to be. Not because of over-provisioning - but because of accumulated inefficiencies nobody had cleaned up.
I didn’t redesign the entire system. Instead, I analyzed the bill, audited the runtime patterns, and fixed what was actually broken. Here’s exactly how we accomplished a 60% reduction in monthly cloud spend while actually improving LCP and request latencies.
The Audit: Where Was the Money Going?
Before making any changes, I analyzed the AWS Cost Explorer billing breakdown line-by-line. The monthly distribution revealed that compute overhead and log retention were dramatically out of proportion with our active user base:
| Service Category | Monthly Cost (Before) | Monthly Cost (After) | % Reduction | Primary Culprit |
|---|---|---|---|---|
| EC2 Compute | $620 | $280 | 54% | Blocked event loop, static serving CPU overhead |
| S3 Storage & Transfer | $380 | $90 | 76% | Indefinite log storage, massive image uploads |
| CloudWatch / I/O | $250 | $110 | 56% | Verbose application request/response dumps |
| Total | $1,250 | $480 | 61% | Overall Savings: $770/month |
1. Fixing Excessive Logging Inefficiencies
Our application log files were growing at an unsustainable rate. The Node.js application was generating verbose logs for every inbound request—including full request and response bodies, heavy SQL trace logs, and absolute stack dumps on trivial input errors.
// ❌ Deprecated verbose pattern that flooded CloudWatch and S3
app.use((req, res, next) => {
logger.info({
url: req.url,
headers: req.headers,
body: req.body, // Large raw JSON buffers
timestamp: new Date()
});
next();
});
These logs were being written to disk and continuously shipped to Amazon S3. Worth knowing before you inherit a bill like this: CloudWatch log groups retain data forever by default, so a retention period is something you have to set deliberately rather than something you can assume.
The Fix
I audited our log configuration and implemented Pino for structured JSON logging. I restricted verbose request/response logging only to non-production environments and defined clear log levels for our production cluster:
- DEBUG/INFO: Used only for crucial system boot details and structured API transaction summaries (method, path, status, duration).
- WARN/ERROR: Reserved for true exception states, database connection alerts, and 500-level codes.
This change alone decreased log volume from ~15GB/day to under 800MB/day, dramatically dropping both write I/O costs and file shipping overhead.
2. Setting Up Automated S3 Lifecycle Policies
S3 storage is inexpensive until files accumulate over months without rules. We were storing every daily server log, PDF bill copy, and redundant image backup indefinitely. There was no cleanup process in place.
The Fix
I configured strict S3 Lifecycle Rules on our logging and static resource buckets:
- Daily Server Logs: Transition to S3 Standard-IA (Infrequent Access) after 14 days, move to S3 Glacier Flexible Retrieval after 30 days, and permanently expire after 90 days.
- Temporary File Uploads: Automatically expire from the
/tmp/bucket prefix after 7 days.
Implementing these automated lifecycle states trimmed down our total active S3 storage footprint by over 70% in the first billing cycle.
3. Offloading Static Asset Serving from Node.js to NGINX
This was the most impactful architectural refinement. The Node.js backend process was serving static frontend assets directly. Whenever a client loaded our web app, the request for a Javascript chunk, CSS bundle, SVG logo, or favicon went straight through our Express application.
Because Node.js is single-threaded, serving static file assets blocks the event loop from doing actual API computations, leading to high CPU usage and queuing lag.
The NGINX Solution
Serving a static file through Node.js means reading it into userspace and streaming it back out through the event loop. NGINX hands the same job to the kernel with sendfile, skipping the copy entirely — which is why this is a change in kind rather than a tuning tweak.
I configured NGINX as a reverse proxy in front of Node.js. NGINX is built from the ground up to serve static files with extremely low memory and CPU overhead. By handling static requests at the NGINX layer, we bypass the Node.js event loop completely:
┌──────────────────────┐
Web Client ───────▶ │ NGINX Reverse Proxy │
└──────────┬───────────┘
/static/* │ /api/*
(CSS, JS, images) │ (REST endpoints)
┌───────────────┴───────────────┐
▼ ▼
┌─────────────────────┐ ┌────────────────────────┐
│ Local Static Files │ │ Node.js / PM2 Cluster │
└─────────────────────┘ └────────────┬───────────┘
▼
┌────────────────────────┐
│ PostgreSQL Database │
└────────────────────────┘
Configured NGINX Routing Block
# NGINX config: serve static files directly, proxy only API requests
server {
listen 443 ssl http2;
server_name app.example.com;
# Static assets served directly by NGINX - never blocks Node.js loop
location /static/ {
root /var/www/frontend;
expires 30d;
add_header Cache-Control "public, no-transform, immutable";
access_log off; # Turn off access logging for static assets to reduce disk I/O
}
# API requests proxied to the PM2 Node.js server cluster
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Offloading this work to NGINX decreased our average EC2 instance memory consumption by 35% and CPU usage by 40%. This allowed us to safely downsize our EC2 compute tier, reducing our monthly server cost significantly.
The Verdict
Optimizing infrastructure isn’t always about moving to serverless or re-architecting your entire system. Usually, the highest return on investment comes from cleaning up operational inefficiencies:
- Logging: Keep production logs actionable, structured, and short.
- Storage: Enforce S3 lifecycle rules from day one.
- Separation of Concerns: Let NGINX serve your files, and let Node.js compute your API logic.
What actually broke in production
Trimming the logs went one step too far on the first pass. I cut request-body logging everywhere, including the payment webhook handler — and then spent an afternoon debugging a failed reconciliation with no record of what the gateway had actually sent us. Verbose logging is expensive, but it is not uniformly worthless: the money paths and the integration boundaries are exactly where you want the full payload retained, just with a short retention window rather than none. I put structured logging back on those routes with a 14-day lifecycle rule.
What I’d still improve
The instance is right-sized for today’s peak, which means it is oversized for the other 22 hours of the day. The honest next step is scheduled scaling around the lunch window rather than a fixed tier, and I have not done that yet because the savings no longer justify the operational surface — which is its own kind of answer.
FAQ
What usually drives an unexpectedly high AWS bill? Accumulated defaults rather than architecture. Logs retained forever because nothing set a retention period, objects sitting in standard storage long after anyone would read them, and compute doing work a web server does better. None of that shows up as a design flaw; it just bills every month.
Is verbose request logging expensive? Very. Logging full request and response bodies on every call inflates ingestion charges, storage, and transfer simultaneously, and it makes incidents harder to debug by burying the useful line. Log structured summaries in production and keep body-level detail for non-production environments.
What do S3 lifecycle rules actually save? They move or expire objects automatically by age, so logs and superseded uploads stop occupying full-price storage indefinitely. The saving compounds, because without a rule every month’s data is added to every previous month’s forever.
Should Node.js serve static files? No, not when a web server sits in front of it. Streaming files through the event loop burns application CPU on work the kernel can do directly, and that CPU is the resource your actual request handling needs during peak.
Does cutting cloud cost mean accepting slower performance? Not for this class of fix. Removing wasted work — logging nobody reads, files served by the wrong process — reduces spend and latency together. The trade-off only appears once you are cutting genuinely useful capacity.
How do you right-size an instance safely? Remove the wasteful work first, measure the new CPU and memory ceiling under real peak load, then downsize against the measured headroom. Downsizing before the load profile changes is how you turn a cost saving into an outage.
The one idea to take away
Read the bill before you read the architecture. Every one of these fixes was visible in the cost breakdown and invisible in the codebase. The instinct when a cloud bill is too high is to reach for a redesign — serverless, containers, a different region. The cheaper move is almost always to find the line item that is disproportionate to your actual traffic and ask what is generating it.
Related: what the deploy pipeline looks like on the same infrastructure, and the request-cycle work that freed up the CPU headroom in the first place.