I run a small company that rents out Bitcoin mining hashrate from a real machine hosted in a professional datacenter. Clients buy a number of TH/s, and every day the mining pool pays them their share directly to their own wallet. I built the whole platform alone: the website, the client dashboard, the daily distribution job and the admin tools.
This post is not a pitch. It is what I learned building software where every bug is somebody's money.
The stack
- Frontend: Angular (standalone components, SSR + prerendering for the public pages, EN/FR with Angular i18n)
- Backend: Node.js + Express, a single service managed by systemd
- Database: MySQL 8
- Hosting: one VPS, Apache in front, exposed through a Cloudflare Tunnel (no open inbound port)
- Mining side: the pool's API for per-client payout splits and daily earnings
Nothing exotic. The interesting part is not the stack, it is the daily job.
The daily job: turning pool earnings into client credits
Once a day at noon, a cron job:
- reads, for each client, what the pool actually paid over the last 24 hours;
- computes the electricity cost and the service fee for that day;
- writes the earnings and expenses shown in the client dashboard;
- checks that every client's payout split on the pool still matches what the contract says;
- posts a report to a private Discord channel.
The pool pays clients directly, so my platform never holds their bitcoin. But the dashboard must show numbers that match reality to the satoshi, and the split configured on the pool must always add up to exactly 100%, or the pool rejects it.
Lesson 1: freeze the behaviour before refactoring money code
The distribution logic had grown into four slightly different copies (precise method, fallback method, manual recalculation, full reset). I wanted one implementation.
Before touching anything, I wrote a characterization ("golden") test: ten realistic scenarios run through all four code paths with a fake database, and every write, Discord message and email recorded into a reference file. Then I refactored until the output was byte-for-byte identical.
The refactor took an afternoon. Without the golden test I would not have dared to deploy it. The first production run matched the reference to the satoshi.
Lesson 2: tests must never reach real services
My tests mock the database, the pool API, Discord and the mailer. They passed on my machine for months. On the production server, one test failed.
The reason: the server's .env contained an email API key, and my code picked a different mailer module when that key was present. The mocks only replaced the other one. So npm test on the server was quietly calling the real email API (with fake example.com addresses, thankfully).
The fix was to blank every external-service variable at the very top of the test setup and to mock both mailer paths. If a test can reach a real service on some machine, it eventually will.
Lesson 3: compare real to theory every single day
Mining rewards are noisy: pool luck, network difficulty, machine uptime. A daily ratio of "real vs theoretical" swings a lot. But the cumulative ratio since the start of each contract is very stable, around 96% in normal operation in my case.
So the job now stores, per client and per day, the theoretical net amount (hashrate × network hashprice − electricity − fee) next to what was actually paid, and posts the cumulative ratio every day. When something drifts (a misconfigured split, an outage), it shows up within days, not months. When I had to compensate clients after an outage, the same table proved the compensation actually closed the gap.
The boring infrastructure that almost bit me
My database config was never loaded
I wanted to raise MySQL's InnoDB buffer pool. I edited mysqld.cnf, restarted, and the value did not change. performance_schema.variables_info said VARIABLE_SOURCE = COMPILED: MySQL was running on built-in defaults.
/etc/mysql/my.cnf was still a symlink to a MariaDB config left over from an old install, which never included the MySQL config directory. For months the server had run with a 128 MB cache for a multi-GB database, and listened on all interfaces (the firewall and private network saved me there). One update-alternatives and mysqld --validate-config later, everything was where I thought it was.
Check where your settings come from, not just what your config file says.
My backups were silently failing
When I audited security, I found the nightly backup cron redirected its log to a directory the user could not write to. When bash cannot open the redirect target, it does not run the command at all. No error anywhere.
Backups now run with a log file I actually read, are kept 14 days locally, and an encrypted copy (GPG, symmetric, key in a password manager) goes off the server every night. A --verify mode downloads the remote copy and decrypts it, because a backup you have never restored is a hope, not a backup.
What I would tell another solo developer
- Write a characterization test before refactoring anything that moves money.
- Make your tests incapable of reaching real services, on every machine.
- Store the theoretical value next to the real one. Drift becomes visible.
- Verify that your config is actually loaded and that your backups actually restore.
- Keep the communication honest: mining income varies every day and is never guaranteed. Saying it upfront builds more trust than any number.
If you are curious about how hashrate rental works from the client side (and its risks), I wrote a plain-language guide here: https://tsmhashdigital.fr/how-hashrate-rental-works/
Questions about the architecture are welcome in the comments.
Top comments (0)