Dev Diary
Building in public · May 2026 onwards

The making of FxLedger.

An honest log of decisions made, bugs found, and lessons learned while building a GDPR-native trading journal for European traders. The good, the broken, and the "why did we think that would work."

Going Live — fxledger.de and the IPv6 Rabbit Hole

Today we migrated to a new Hetzner server, registered fxledger.de, and went live with HTTPS. It should have taken an hour. It took most of the morning — but it was worth it.

The migration

Moving from the old server to the new one was mostly smooth. PostgreSQL dump, Docker volume backup for uploads, copy the .env file, restore on the new machine. The only surprise: the uploads directory didn't exist on the host — it was inside a Docker named volume at /var/lib/docker/volumes/.... You can't just tar the directory, you have to go through Docker's volume path.

Let's Encrypt and the IPv6 problem

Certbot kept failing with a 404 on the ACME challenge. Baffling, because our test file was accessible from our own machine. The error always came from the same IPv6 address: 2a01:4f8:d0a:27bd::2.

The culprit: The DNS AAAA record for fxledger.de pointed to the old server's IPv6 address. The new server has no public IPv6 at all (only a link-local address). So Let's Encrypt was connecting to the old server — which had no challenge file — and getting 404.

Solution: delete the AAAA record entirely. The new server runs IPv4-only and that's fine. After that, Certbot issued the certificate in seconds. Lesson: always check that your DNS records actually point where you think they do before debugging nginx.

There was also a secondary issue: nginx's default site was catching requests before our fxledger.de server block got a chance. Removing it from sites-enabled fixed that. Now the redirect chain is exactly what it should be: HTTP → HTTPS, www → apex domain, and the app loads cleanly.

Result: https://fxledger.de is live, certificate valid until 28 August, auto-renewal configured by Certbot.

The Landing Page, a Countdown, and a Waitlist

With the app working, the next question was: how do people even find it? We don't have a public landing page yet — users land directly in the login screen. That needed to change before we could start talking about "Founding Members."

The flow

The new user journey now looks like this: ConsentGate (GDPR-blocking overlay, must choose before proceeding) → Landing Page (value proposition, opt-in form, countdown) → Sign in → App. Clean separation of concerns.

The Founding Member offer

We set a deadline: 30 June 2026. Anyone who joins the waitlist before then gets locked-in pricing for life. The countdown on the landing page ticks in real time — a small psychological nudge that also happens to be true.

Copy decision: "Your trades. Your data. Your rules." came naturally — it captures the core value proposition (ownership, privacy, control) without being jargon-heavy. The italic accent on "Your data." gives it typographic weight.

The waitlist backend

A simple POST /api/waitlist endpoint, a new waitlist table in PostgreSQL. Stores email + consent timestamp. ON CONFLICT DO NOTHING so double-submissions don't throw errors. No newsletter platform yet — just our own database for now.

The GDPR checkbox

The opt-in form has a mandatory consent checkbox with an explicit text: "I agree that FxLedger may contact me... I can withdraw this consent at any time." Linked directly to the Data Protection Declaration. No pre-checked boxes, no dark patterns.

The Drawdown Bug: −96% with +$698 P&L

The dashboard showed a worst drawdown of −96.2% while the total P&L was +$698. The user's reaction was exactly right: "those two numbers can't coexist." They were mostly correct — but debugging it took three rounds.

Round 1: The missing account balance

Drawdown was calculated as a percentage of the cumulative P&L peak — not as a percentage of the actual account balance. If the equity curve peaked at +$800 and dipped to +$31, that's −96% of the peak. Mathematically valid, practically meaningless. Fix: pass the account starting balance to computeStats() so drawdown is calculated relative to real capital.

Round 2: The iteration direction bug

Fixing the balance didn't help. The number barely moved. Closer inspection revealed that the trades from the API arrive newest-first (sorted by date DESC), but the drawdown loop was iterating in that order — effectively travelling backwards through time. Peak and trough were being detected in reverse chronological order, producing completely wrong results.

Same bug, three loops: The current win streak, the highest win streak, and the drawdown calculation all had wrong iteration order. Streak was counting from the oldest trade, drawdown was computing backwards. All three fixed in one pass.

Round 3: The display gap

Even with correct math, if no account balance is set (default: 0), the drawdown percentage is meaningless. Solution: when no balance is configured, the drawdown stat shows "set balance in settings" in red instead of a confusing number. A small UX touch that guides the user to do the right thing.

The lesson: when you process time-series data, always be explicit about which end is "first." Newest-first vs oldest-first is the kind of bug that hides perfectly until you look at the actual numbers.

Load Testing: What Breaks at 100 Concurrent Users

Before opening registration, we wanted to know: what happens under real load? We used k6 to simulate 100 concurrent users each registering, logging in, creating trades, and fetching the trade list. The first run was humbling.

Run 1: p95 latency 20 seconds, 21% failures

Immediately obvious something was very wrong. Registration took up to 30 seconds. Hypothesis: bcrypt is slow. We were using cost factor 12 — around 400ms per hash. With 100 concurrent registrations, the thread pool was saturated. Reduced to cost factor 10 (OWASP standard, ~100ms), set UV_THREADPOOL_SIZE=16. Improvement: marginal.

Run 2: Still slow. The real bottleneck.

The problem wasn't bcrypt — it was the PostgreSQL connection pool. The default max is 10 connections. With 100 concurrent users, 90 of them were queuing for a database connection. Every request waited. Fix: max: 50. Improvement: significant.

Run 4: 0% errors, 100% success rate. Register p95: 2.94s. Trade creation p95: 2.58s. All thresholds green.

Run 3: Username collisions in the test script itself

Between runs 2 and 4, we had a weird problem: 409 Conflict errors on registration, even with a low error rate overall. The k6 script was generating usernames like user_{timestamp}. When 100 VUs run in the same second, they all generate the same username. Every concurrent registration collided.

Fix: use k6's built-in __VU (virtual user ID) and __ITER (iteration counter) to generate guaranteed-unique names: u{__VU}_{__ITER}. Simple, collision-proof, and it exposed the real server performance without test-script noise.

Load testing is worth doing early. The connection pool issue would have been invisible in manual testing and catastrophic at launch.

The Foundation: Stack, Four Themes, and First Trades

Every product starts with a stack decision that you'll live with forever. For FxLedger we went with React + Vite on the frontend, Node.js + Express on the backend, PostgreSQL for storage, and Docker Compose for everything. Deployed on Hetzner — a German cloud provider — which matters for GDPR: data stays in EU data centres.

The theme system

One early decision we're glad we made: build the design system as a 2D theme matrix from day one. Two design families (Linen and Hyper) × two modes (light and dark) = four themes. Every colour, font, and shadow is a token on the theme object t, passed as a prop through the entire component tree.

Linen is warm and editorial — inspired by financial newspapers. Serif headings (Newsreader), earthy neutrals, muted wins and losses. Hyper is a glass-morphism dark theme with violet-to-cyan gradients, glows, and bloom effects. Completely different in character, built on the same component structure.

Font as design token: Hyper doesn't use serif fonts. Its t.serif slot is set to Inter — the same as t.sans. This means every component that uses t.serif automatically adapts without needing to know which theme it's in.

Schema migrations

The database schema evolved significantly in the first weeks. We renamed a column (result_eurresult_amount), added a two-dimensional theme system (design + color_mode instead of a single theme), and added account balance and currency settings. All migrations run idempotently on startup — safe to deploy without downtime, safe to run twice.

What we'd do differently

We built the app without a client-side router (no React Router, no URL-based navigation). All navigation is internal state. This was simpler to start but caused a subtle problem with the legal pages: they can't be linked to directly, and the browser back button doesn't work as expected. Something to revisit when the app grows.