Krabber: a Twitter clone in Go for under $100 a month
What it is
Krabber is a Twitter clone. It’s a Go port of Crabber, an open-source Flask site, and it’s live at krabber.net. I pay for it myself, so every design decision starts with what it costs.
Users are krabs. A krab writes molts (280 characters), remolts and quotes other krabs’ molts, replies, likes, and follows. The Trench is your home feed, made of molts from the krabs you follow. The Sea is everyone. There are also notifications, bookmarks, krabtags with trending, mentions, blocks, trophies, and a generated krab avatar for every account.
Moderation lives in Krabmin: a report queue, bans, warnings, removing and restoring molts, and a moderation log. Admins can only be made from a terminal, never from the site.
How a request flows
browser
│ HTTPS
▼
CloudFront + WAF static assets cached at the edge
│ HTTPS, CloudFront IPs only, secret header
▼
Elastic Beanstalk one t4g.micro: nginx → Go server
│ server-rendered HTML + htmx
▼
DynamoDB one table: data, sessions, rate limits
CloudFront terminates TLS, WAF filters traffic, and static files are cached at the edge. Everything else goes to a single Go server on an Elastic Beanstalk t4g.micro, which renders HTML and uses htmx for the interactive parts. No client-side framework. Sessions and rate-limit counters live in the same DynamoDB table as the data.
Background work, like fanning new molts out to followers’ Trenches, runs in the same process. A molt waiting for fan-out is marked in DynamoDB, so the next instance finishes the job after a deploy or a crash.
Keeping it cheap
The goal: under $100 a month at 10,000 daily active krabs, and a bill with a known ceiling even under attack.
At hobby traffic it’s about $11 a month, mostly the instance, its public IP and its disk. CloudFront runs on the flat-rate Free plan, which includes WAF, DDoS protection and DNS, and never bills overage. As traffic grows, the model looks like this (three accounts per daily active krab, storage after a year at that size):
| Daily active krabs | Accounts | CloudFront plan | Total a month |
|---|---|---|---|
| 100 | 300 | Free | about $12 |
| 500 | 1,500 | Free | about $14 |
| 1,000 | 3,000 | Pro ($15) | about $31 |
| 2,500 | 7,500 | Pro ($15) | about $38 |
| 5,000 | 15,000 | Pro ($15) | about $49 |
| 10,000 | 30,000 | Pro ($15) | about $81 |
At 10,000 the instance moves up to a t4g.small, because memory runs out before CPU does.
Writes cost the most. Likes are 55% of them, because a like writes the like, its index entry and a notification. Trench fan-out is another 26%: every molt is written once more per follower. Reads come next, and feed pages are 41% of those, mostly loading 20 molts each.
I didn’t guess these numbers. A meter on the DynamoDB client adds up the capacity each request consumes. TestCostProfile seeds 25 krabs, loads every main page, poll and action, and fails if one goes over its budget. A script turns those units into the table above. In production, every logged request carries its read and write units, so one query shows which pages cost what.
Measuring paid off. A feed page used to cost 45–47 read units because it looked up likes, remolts and bookmarks one molt at a time. Now it’s one range query per type, and 16–18 units. Polling dropped to once a minute, only while the tab is in use. Before those changes, 10,000 daily active krabs would have cost about $134.
The guard rails:
- DynamoDB maximum-throughput caps on the table and every index. Requests above a cap are throttled, not billed. The app paces its own bursts, like fan-out, to stay under them.
MAX_KRABScloses signups once that many krabs can sign in. It launched at 3,000, and I raise it in steps only after a real month’s bill matches the model.- Per-krab hourly write limits: 100 molts, 1,000 likes, 300 follows, 300 bookmarks. No person gets near them, but they bound what one account or a stolen session can cost.
- A daily cap on outgoing email, budgets with alerts, and a maintenance switch that blocks everything at WAF for $0.
One DynamoDB table
Everything sits in one table with a generic partition key and sort key. Related items can be read together, transactions span entity types, and there’s one set of throughput caps to reason about.
The main access patterns:
- A krab’s molts share a partition, sorted by a time-ordered ID, so a profile is one query.
- The Trench is a partition per krab holding pointers to molts. Posting writes one entry per follower in the background. Reading a page is one query plus one batch read.
- The Sea comes from an index keyed by day, so the newest molts are one query.
- Likes and follows are stored under the krab who acted, with an index for the other direction: who liked a molt, who follows a krab.
- Uniqueness for emails, usernames and avatars comes from conditional puts on marker items.
- Background work uses a sparse index. Only items with pending jobs appear in it, so it stays tiny.
Sessions, notifications and Trench entries expire with DynamoDB’s free TTL. The molt indexes store keys only, so a like doesn’t copy the molt into two indexes.
Security
- Strict CSP. No inline scripts and no
eval; htmx runs with both turned off. - CSRF in layers: Go’s cross-origin protection, CSRF tokens in every form, POST for every change, and
SameSite=Laxcookies. - Sessions in DynamoDB. Only a hash of the session token is stored. The token rotates on login, and a password change, reset or ban signs out every device.
- WAF rate limits per IP, with a tighter one on login, signup and reset paths, plus AWS’s managed IP reputation list.
- CloudFront-only origin. The security group accepts traffic only from CloudFront’s IP list, and the app rejects any request without a secret header set by CloudFront.
- Origin TLS. A single instance has no load balancer to hold a certificate, so nginx uses an ACM exportable certificate that ACM renews and the instance re-exports daily.
- Login rate limiting per IP and per email, with bcrypt at cost 12. A login for an unknown email checks against a dummy hash, so it takes as long as a real one.
- Timing-safe reset and resend. Both always give the same answer. The lookup and the email happen after the response goes out, so the response time doesn’t reveal whether an account exists.
- Moderation tools in Krabmin, with every action logged.
Shipping it
The integration tests run against DynamoDB Local, with the same table definition as production.
Deploys run from my laptop, which costs no CI minutes. make deploy refuses to run with uncommitted or unpushed changes, runs the checks, and ships the commit. Beanstalk deploys are immutable: a fresh instance boots, traffic switches only once it’s healthy, and it’s thrown away if it isn’t. Then the commit is tagged and a GitHub release is created automatically.
Infrastructure is Terraform: the table and its caps, Beanstalk, CloudFront, WAF, certificates, email, alarms and budgets. Only a handful of one-time steps, like securing the account and picking the CloudFront plan, are done by hand.
What’s next
- A REST API. It’s designed but not built: personal keys that act as the krab who made them, read and write scopes, three layers of rate limits, and page limits so it’s no easier to scrape than the site.
- RSS for krabs and krabtags.
- Avatar and banner uploads, optionally, to a locked-down bucket.
Come molt with us at krabber.net.