Two weeks ago I checked Cloudflare Web Analytics and saw 4,500 pageviews in one day. My heart jumped. Then I looked closer. The GraphQL analytics endpoint showed 98% of those "requests" were my own auto-refresh polling โ the dashboard hits the CoinGecko API every 60 seconds, and Cloudflare counts each of my own fetches as a pageview event. 11,000+ "requests" in a single weekend, mostly from a browser tab I left open on my laptop while I went grocery shopping.
Real numbers: 61 pageviews and 8 actual human visits in the last 24 hours. I built a crypto data dashboard called BitPilot over several weekends. It costs $7/month to run on a Vultr VPS. It has generated exactly $0 in revenue across three exchange affiliate programs. I'm posting this not because I cracked some growth hack โ I haven't โ but because the gap between polished Show HN launch posts and what most side projects actually look like is massive. Somebody should document the messy middle.
Also, maybe one of you will visit and bump that number to 9. A solo dev can dream.
The Stack
The whole thing is a single Docker container running Nginx on a $6/month Vultr VPS (1 vCPU, 1 GB RAM, 25 GB SSD). Cloudflare sits in front for CDN and DNS. That's the entire infrastructure โ no Kubernetes, no microservices, no serverless, no Redis. One box, one container, one nginx.conf. I could probably run this on a Raspberry Pi in my closet, but the VPS gives me a static IP and I don't have to explain to my ISP why my residential connection is pinging crypto APIs every 60 seconds.
Frontend: Tailwind CSS for layout, Chart.js for the charts. Vanilla JavaScript. No React, no Vue, no build step, no webpack config I'd have to debug at 2 AM. The JS is maybe 800 lines total โ fetch() calls, DOM manipulation, Chart.js config objects. It's not elegant code. There are functions named updateFngGauge() and renderDominancePie() that I wrote at 11 PM and haven't touched since. They work.
Backend-for-frontend pattern: There is no backend. BitPilot is a static site. All crypto data comes from free APIs fetched client-side. But CoinGecko's free API doesn't set permissive CORS headers, so I route everything through Nginx reverse proxy location blocks on the same origin. More on this below โ it's the one architectural decision I got right.
Data sources: CoinGecko free API (prices, market caps, 24h change), Alternative.me Fear & Greed Index. Both are rate-limited but generous enough for single-digit DAU. Chart.js renders the line charts, the dominance pie chart, and the Fear & Greed gauge.
Three Bugs That Made Me Question My Life Choices
Every side project has bugs. These are the ones that stuck with me โ not because they were hard to fix, but because they were so stupid I couldn't believe I shipped them.
Bug #1: The Gate.io URL That Ate Itself
I added affiliate referral links to three exchanges โ Binance, Bitget, and Gate.io. Standard stuff: your referral code goes in a URL parameter, the exchange tracks signups, you get a commission if someone trades. I copied the referral links from each exchange's partner dashboard and pasted them into my HTML.
Gate.io's referral URL, when generated through their partner portal, comes out to 214 characters. It's a redirect chain wrapped in tracking parameters wrapped in UTM tags. I didn't notice this because I never clicked the link myself. A week later I opened the Network tab in dev tools and saw the browser making three sequential 302 redirects before landing on Gate.io's homepage. The final URL in the address bar didn't even contain my referral code โ it had been stripped somewhere in the redirect chain.
I spent 45 minutes tracing the redirects with curl -L -v before realizing Gate.io's partner portal was generating a URL that literally did not work. The fix was to strip everything down to the bare referral path: https://www.gate.io/signup/<code>. Twelve characters. The 214-character monster had been silently broken the entire time. Even if someone had clicked it โ and nobody did โ it wouldn't have tracked.
Bug #2: The about.html That Shipped With Line Numbers
I generated the About page using an LLM pipeline โ the same one I used for the blog posts. The pipeline outputs markdown, then a script converts it to HTML and injects it into the template. Except I forgot to strip the LLM's response prefix. The model had helpfully numbered the lines of its output:
1. <h2>About BitPilot</h2>
2. <p>BitPilot is a free cryptocurrency data dashboard...</p>
3. <p>Built by a solo developer...</p>
And that's exactly what shipped to production. For three weeks, /about.html displayed the numbers "1.", "2.", and "3." as literal text on the page, floating above each paragraph like some kind of avant-garde formatting choice. Nobody reported it because nobody visited the About page. I only found it when I was clicking around the site during a midnight debugging session and thought, "Wait, why does my About page look like a code review?"
The fix was a single sed command: strip leading line numbers from the markdown before conversion. But the real lesson was that nobody saw the bug because nobody saw the page. You can ship broken HTML and the world will never know.
Bug #3: CJK Characters Leaking Into English SEO Pages
My blog post generator uses an LLM with a system prompt in English. But the model I chose (a Chinese-hosted provider โ it was $0.04/article, which should have been my first red flag) occasionally outputs CJK punctuation and characters even when instructed to write in English. Specifically, it would sometimes use full-width commas (๏ผ) instead of half-width commas (,), and on two occasions it inserted Chinese quotation marks (ยซยป) into what was supposed to be an English blog post about DeFi yield farming.
Google indexed these pages. My "Crypto Tax Guide 2026" article had a meta description that, when rendered in certain browsers, showed garbled characters because the CJK punctuation confused the encoding. The page still ranked for some long-tail keyword โ the content was technically in English โ but anyone who landed on it from search probably thought the site was compromised.
I caught it when I was reviewing the 20 auto-generated posts in bulk and noticed weird spacing around punctuation. A grep for Unicode ranges \x{FF01}-\x{FF5E} and \x{3000}-\x{303F} found 14 posts with leaked CJK characters. The fix was a post-processing script that normalizes all punctuation to ASCII equivalents. But the damage to first impressions from anyone who hit those pages in the first two weeks? Can't fix that.
CORS Proxying: The One Thing I Got Right
I want to talk about this because it's genuinely useful and doesn't get written about enough. If you're building a static SPA that needs data from third-party APIs, you have three options for CORS:
- Build a backend server โ Express, FastAPI, whatever. Now you have two things to deploy and maintain.
- Use a public CORS proxy โ something like
cors-anywhere. Now your site depends on a third-party service that could disappear or rate-limit you tomorrow. - Nginx reverse proxy on the same origin โ a few lines of config, zero backend code, zero external dependencies.
Option 3 is the sweet spot. Here's the actual Nginx config from BitPilot:
location /api/coingecko/ {
resolver 8.8.8.8;
proxy_pass https://api.coingecko.com/api/v3/;
proxy_set_header Host api.coingecko.com;
proxy_ssl_name api.coingecko.com;
proxy_ssl_server_name on;
proxy_cache coingecko_cache;
proxy_cache_valid 200 60s;
proxy_cache_use_stale error timeout updating;
}
That's it. My client-side JS fetches /api/coingecko/simple/price?ids=bitcoin&vs_currencies=usd like it's my own endpoint. Nginx forwards to CoinGecko, caches the response for 60 seconds, and serves stale cache if the upstream times out. Zero server-side application code.
The proxy_cache directive is the part people miss. CoinGecko's free tier allows 10-30 calls per minute. My dashboard auto-refreshes every 60 seconds. Without caching, every open browser tab would hit CoinGecko directly โ 8 visitors with 2 tabs each = 16 calls/minute, right at the rate limit. With a 60-second cache, Nginx only hits CoinGecko once per minute total, regardless of how many tabs or visitors are polling. This is basically free rate-limit evasion at the infrastructure layer.
I use the same pattern for the Fear & Greed Index API. Adding a new data source takes one location block โ maybe 8 lines of config.
If you take one thing from this post
Static SPA + Nginx reverse proxy + proxy_cache = a poor man's API gateway that handles CORS, rate limits, and caching with zero application code. This pattern works for any REST API. I've used it for CoinGecko, Alternative.me, and could add more data sources in minutes.
Docker Permission Hell and the Cloudflare Token Incident
The Let's Encrypt SSL setup was supposed to be straightforward: run certbot in its own container, mount a shared volume for certificates, have Nginx read them. Standard Docker pattern.
Certbot runs as root inside its container. Nginx worker processes run as UID 101 (nginx:nginx). The certificate files certbot writes are owned by root with 600 permissions. Nginx can't read them. SSL terminates with a cryptic "permission denied" in the error log and your site serves a Cloudflare 526 error to everyone.
My first fix attempt: a certbot post-renewal hook that runs chown -R 101:101 on the live cert directory. This doesn't work across Docker named volumes โ the chown runs inside the certbot container where UID 101 might not even map to anything. I ended up switching to a bind mount on the host filesystem, setting the volume to :ro for the Nginx container, and running a cron job on the host that fixes permissions after renewal. This took an afternoon and generated a commit message that was just the word "certificates" typed in increasing frustration: certificates CERTIFICATES CERTIFICATES!!1.
Then there was the Cloudflare API token incident. I wrote a shell script that updates Cloudflare DNS A records when the VPS IP changes (it shouldn't, but Vultr doesn't guarantee static IPs on the $6 tier). The script uses the Cloudflare API with a bearer token. I hardcoded the token into the script. I pushed the script to a public GitHub repo. I realized what I'd done approximately four minutes later while brushing my teeth.
I rotated the token immediately, ran git filter-branch to scrub it from history, force-pushed, and prayed. Cloudflare's API token permissions are granular enough that the leaked token only had DNS:Edit on a single zone, not account-level access, but that's a distinction I learned after the panic. The token permission model in Cloudflare's dashboard is genuinely confusing โ there are like 18 different permission scopes and they're named things like "Zone:Cache Purge:Edit" and "Zone:SSL and Certificates:Read" and I'm pretty sure I granted the wrong permissions twice before getting it right.
The Numbers, No Filter
Here's the actual state of this project, no rounding up, no "engagement metrics," no startup-speak:
Traffic Reality (past 24 hours, Cloudflare Web Analytics)
Pageviews: 61. Unique visitors: ~190 (including all bot/crawler traffic). Actual humans: 8. The auto-refresh polling from my own open browser tabs generates ~11,000 "requests" per weekend, all of which Cloudflare counts as events. Googlebot, GPTBot, ClaudeBot, and a dozen other crawlers account for most of the rest. If you strip out non-human traffic, the site gets maybe 15-20 real people per day, most of whom land on an SEO page, skim for 12 seconds, and close the tab.
Blog content: I auto-generated 20 blog posts via an LLM pipeline targeting long-tail crypto SEO keywords. Each post cost roughly $0.04 in API credits. Total content investment: $0.80. The posts technically rank for obscure queries โ someone found "fear and greed index crypto explained" via Google last week and spent 47 seconds on the page โ but the bounce rate across all blog pages is north of 90%. Nobody subscribes to anything because there's nothing to subscribe to. The blog doesn't have an email capture form because I never built one.
Twitter / X (@BitPilot_io): I've posted 30 tweets. I have 0 followers. Not a typo. Zero. I've tried hashtags, I've tried replying to bigger accounts, I've tried posting at different times of day. Nothing. Cold-starting a Twitter account in 2026 is like shouting into a hurricane โ your voice doesn't carry, and even if it did, the algorithm buries accounts with no engagement history. The only likes I've gotten are from bots promoting "crypto signal groups" with stolen profile pictures.
Monetization: I embedded affiliate referral links to Binance, Bitget, and Gate.io. Across all three programs, across the entire lifetime of the site: zero clicks, zero signups, zero commissions, $0.00. I don't even know if the links work (see Bug #1 above).
Monthly costs:
- Vultr VPS (1 vCPU, 1 GB RAM, 25 GB SSD): $6.00
- Domain (bitpilot.io, amortized monthly): ~$0.50
- Cloudflare: $0 (free tier)
- CoinGecko API: $0 (free tier)
- SSL certificates (Let's Encrypt): $0
- LLM API credits for blog generation: ~$0.50/month
- Total: ~$7/month
At this burn rate, my runway extends until approximately the heat death of the universe. Whether that's a flex or a concession depends on your perspective.
What I'd Do Differently
If I woke up tomorrow with the urge to build another side project โ and I probably will, because apparently I don't learn โ here's what changes:
- Skip the AI-generated blog SEO play. I have 20 auto-generated articles that rank for nothing important, get no backlinks, and probably damage the site's credibility more than they help. I'd rather have 3 deeply researched, hand-written technical posts โ one about the Nginx reverse proxy pattern, one about building dashboards with Chart.js, one about the economics of free crypto APIs โ than 20 LLM-generated listicles about "Top 10 DeFi Protocols." The blog posts that took actual effort get 10x the engagement of the auto-generated ones. The market can tell the difference.
- Launch after week one, not week six. I spent weeks on chart animations, color palettes, responsive grid breakpoints, and the Fear & Greed gauge gradient. Nobody saw any of it. I could have shipped a single-page dashboard showing Bitcoin's price and 24h change in a weekend. Polish is a trap for solo devs โ your gradient transitions don't matter when your user count rounds to zero.
- Build distribution first, product second. If I'd built BitPilot as a Telegram bot that DM'd you the Fear & Greed index every morning, I'd have a built-in distribution channel. If I'd built it as a daily crypto email newsletter with charts, same thing. A standalone website with no audience is just a file on a server. The code was the easy part โ getting anyone to look at it was the hard part, and I spent 95% of my time on the easy part.
- Put a real human behind it. BitPilot is anonymous. There's no face, no name, no personal story. The About page (when it wasn't displaying line numbers) was generic. People try things built by people, not things built by brands. If I'd written "Hey, I'm [name], a dev who wanted a clean crypto dashboard without ads" and put my actual GitHub profile there, I'd have more credibility than any number of affiliate links.
- Test production before celebrating analytics. I celebrated 4,500 "pageviews" before realizing they were all polling requests. I celebrated "SEO traffic" before realizing the CJK leak had corrupted my meta descriptions. Every vanity metric I got excited about turned out to be noise. The only number that actually matters โ real humans using the product โ I ignored because it was hard to measure and depressing to look at.
Why I Haven't Deleted the VPS
I still use BitPilot. I open it a few times a day to check Bitcoin's price, the Fear & Greed index, and the dominance chart. It's genuinely useful to me. That's worth $7/month.
Making it was fun in the way that solving a puzzle is fun. Debugging the CORS issue at midnight, watching the Chart.js canvas render for the first time, figuring out the proxy_cache trick โ those moments are why I write code. The product doesn't need to make money or get traffic for those moments to have been real.
And maybe โ genuinely maybe โ one of those 8 daily visitors will click an affiliate link and I'll wake up to a $0.50 commission notification. A man can dream.
Try BitPilot
Free crypto data dashboard. No ads, no accounts, no signup. Built by one dev, running on a $6/month VPS.
bitpilot.io โSource on GitHub โ
โ ๏ธ Affiliate Disclosure: BitPilot contains referral links to cryptocurrency exchanges (Binance, Bitget, Gate.io). If you sign up through these links and trade, BitPilot may earn a commission โ though to date it has earned exactly $0.00 from all affiliate programs combined. This article is for educational and entertainment purposes only and does not constitute financial advice. Cryptocurrency investments involve substantial risk of loss. Always conduct your own research.