"The complete architecture of a screenshot API that runs entirely on free-tier services — Cloudflare Workers, D1, R2, and an Oracle ARM instance running headless Chromium."
tags: cloudflare, api, indiehackers, tutorial
cover_image: https://shotlyapi.in/logo.svg
I ran a Puppeteer cluster on AWS for a year. Three EC2 instances, a load balancer, auto-scaling rules that nobody on the team fully understood. The monthly bill crossed Rs.15,000 (~$190) — for screenshots.
Chrome ate RAM like a monster. Cold starts took 15 seconds. Memory leaks appeared every other week. I SSH'd into servers at 2 AM more times than I'd like to admit, restarting PM2 and praying the process survived till morning.
Then I tore it all down and rebuilt everything on free-tier services only. The result: ShotlyAPI — a screenshot API with 21 parameters, running on Rs.0/month infrastructure. And it's faster than the AWS setup was.
This article is the complete architecture, including the gotchas nobody tells you about.
The Stack
Service Purpose Free Tier
Cloudflare Workers API gateway, auth, billing 100K requests/day
Cloudflare D1 User data, usage tracking (SQLite) 5GB storage
Cloudflare R2 Screenshot cache (S3-compatible) 10GB storage, zero egress fees
Oracle Cloud ARM Headless Chromium rendering server 4 OCPUs, 24GB RAM — Always Free
Cloudflare Tunnel Connects Oracle server to the edge Free, unlimited
Resend Transactional email 3K emails/month
Total monthly cost: Rs.0.
Why This Combo Works
- Cloudflare Workers as the front door The Worker does everything except rendering: API key authentication, plan/usage checks against D1, rate limiting, Razorpay billing (we're targeting Indian developers, so INR pricing matters), and caching lookups against R2.
// Simplified request flow
export default {
async fetch(request, env, ctx) {
const apiKey = getApiKey(request)
const user = await getUserByApiKey(env, apiKey) // D1 lookup
if (used >= limit) return upgradePrompt() // sales happens here
const cached = await env.SCREENSHOTS.get(cacheKey) // R2 hit?
if (cached) return new Response(cached) // <500ms response
return forwardToOracleServer(params) // ~3s response
}
}
The genius of Workers here: your API lives on Cloudflare's edge network — the same 300+ locations serving their DNS. Latency to Indian users is under 20ms.
- R2 for the screenshot cache (the secret weapon) Every screenshot gets stored in R2 with a 7-day lifecycle rule. A cache key is built from the URL + all parameters, so identical requests are cache hits. This changed the economics completely: First request for a URL: ~3 seconds (full Chromium render) Every repeat request: under 500ms (R2 edge cache, zero egress fees) Zero egress fees is the killer feature. On S3, serving screenshots would cost money per GB transferred. On R2, it's free — reads from the free tier don't count against you the way S3 does.
- The Oracle Cloud Always Free tier (the part nobody believes)
This is where the actual Chromium rendering happens. Oracle's Always Free tier includes:
4 OCPUs (Ampere ARM)
24GB RAM
200GB storage
No time limit — "always" actually means always
24GB of RAM is genuinely enough to run a Puppeteer fleet. Chromium instances for screenshot capture are short-lived — launch, render, capture, kill. With proper process management (PM2 +
--no-sandboxflags tuned for ARM), the instance comfortably handles hundreds of concurrent captures. The gotchas: ARM64, not x86.apt install chromium-browseron Ubuntu ARM works, but some Puppeteer versions expect x86 paths. PinexecutablePath: '/usr/bin/chromium-browser'explicitly. You need a credit card for verification — but they never charge you. A debit card works (this matters in India where credit card penetration is low). Capacity regions matter. Some regions are perpetually "out of capacity" for the free ARM shapes. Keep trying — it took me three days to get an instance in a usable region. - Cloudflare Tunnel: no open ports, no public IP exposure
The Oracle server never exposes a port to the internet. Instead,
cloudflaredruns as a systemd service and creates an outbound-only tunnel:
tunnel.shotlyapi.in → localhost:3000 (Node.js + Puppeteer)
The Worker forwards render requests to this tunnel URL with a shared secret header. If the secret doesn't match, the request dies at the door. No port scanning, no DDoS surface, no certificate management.
The Request Flow (Step by Step)
Client sends GET /api/screenshot?url=https://example.com&full_page=true with an Authorization: Bearer header
Worker validates the API key against D1 (keys are stored as SHA-256 hashes — never plaintext)
Worker checks plan limits: free (20), trial (100/7 days), starter (2000/mo), etc.
Worker builds a cache key from URL + params and checks R2
Cache hit → return image from edge in <500ms. Done.
Cache miss → forward to the Oracle server via the Tunnel
Puppeteer launches Chromium, renders the page, captures PNG/JPEG/PDF
Screenshot stored in R2 (7-day lifecycle), returned to the client (~3s)
Usage logged to D1 for analytics and billing
The Business Layer
Because the Worker handles auth and billing, the architecture supports a full SaaS:
Free tier: 20 screenshots, no credit card — signup takes 30 seconds, you get an API key immediately
Trial: Rs.99 one-time for 100 screenshots/7 days
Subscriptions: Starter Rs.499/mo, Growth Rs.899/mo, Pro Rs.1,799/mo via Razorpay
The upgrade path is organic: when a free user hits 20 screenshots, the API returns a friendly error with the billing link. The limit IS the salesperson.
Everything is tracked in D1: signups, usage, payments (via Razorpay webhooks), page views. A separate admin dashboard reads all of it — same free stack, same Rs.0.
What I'd Do Differently
Three honest lessons:
I'd add rate limiting on day one, not month three. Free tiers attract scrapers. A simple IP-based limiter in D1 (10 requests/minute) killed 95% of abuse.
ARM compatibility took longer than expected. Budget a full day for Puppeteer-on-ARM debugging before the architecture feels stable.
The 10GB R2 free tier fills faster than you'd think at high volume. The 7-day lifecycle rule is what keeps it sustainable — set it up before launch, not after your first alert.
The Numbers
Metric AWS (old) Free-tier (new)
Monthly cost Rs.15,000+ Rs.0
Cold start ~15s ~3s (warm server)
Cached response ~2s <500ms
Maintenance hours/week 5-8 ~0
Uptime Heart-monitor 99.9%+
Try It
The API is live at shotlyapi.in. There's a free tier — 20 screenshots, no credit card, no expiry:
Signup (30 seconds): shotlyapi.in/signup
Try without an account: shotlyapi.in/playground
Code examples (cURL, Node, Python, PHP, Go): github.com/MyWorld0007/shotlyapi-examples
One line of code:
curl -G "https://api.shotlyapi.in/api/screenshot" \
--data-urlencode "url=https://example.com" \
-H "Authorization: Bearer YOUR_API_KEY" \
-o screenshot.png
Happy to answer any architecture questions in the comments — especially about the Oracle ARM setup, since that's the part most people ask about.
Top comments (0)