Dev Diary
Building in public · May – June 2026 · updated 17 June

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."

An Admin Panel, Support Mail, and the Missing Delete Button

Once real users exist, two things happen quickly: someone writes to support, and you notice a workflow you never needed while building alone. This week was mostly about answering the first kind of message faster — and fixing the second kind of gap before it annoyed anyone else.

A small admin area for the people who run FxLedger

We added an internal Admin section in the app — visible only to addresses we explicitly allow. Nothing fancy: a overview of sign-ups and plans, a searchable user list, and enough controls to help without opening the database by hand.

The everyday cases are mundane but real. A user registers but the verification email lands in spam — we can mark the address as confirmed. Someone asks for a plan change after a billing hiccup — we can set it directly. Someone wants their account removed — we can delete it, but only after typing their email address back to us, so a mis-click cannot wipe the wrong person. You cannot delete your own admin account from there, which is deliberate.

When delete silently failed: The first version of account deletion in admin looked broken — a generic German error, no explanation. The confirmation field was filled in correctly; the request simply did not always carry through. Some setups treat certain kinds of web requests as “body optional” and drop what you sent. Switching to a more reliable request shape fixed it. The panel now shows a specific message when something actually goes wrong — wrong email, Stripe still attached, and so on.

We also put each user’s ID in the list, before the email address. Support threads are easier when both sides mean the same account — especially when two people share a name or someone typo’d their address in a ticket.

Support inbox on our own domain

Alongside admin, we stood up support.fxledger.de — a proper helpdesk for contactme@fxledger.de. Incoming mail is fetched automatically; we wrote a bilingual auto-reply (German and English in one message) so nobody waits on an empty inbox overnight. Tags and saved-reply macros would be nice later; for now, internal notes and copy-paste templates are enough.

The delete button that was never there

Editing a trade had save and cancel. Deleting one — something you expect in any journal — had no button at all. You could remove a trade only if you knew where to look elsewhere, which is not a fair bar for normal use.

We added a red Delete trade control at the bottom of the edit screen, with a confirmation step so a stray click does not erase history. First draft stacked it under the other buttons; it felt disconnected, like a footnote. The layout that stuck puts Update, Cancel, and Delete on one row — save actions on the left, delete on the right, where your eye already looks for “danger.” Small layout decision; much clearer in practice.

What “done” looks like: A support mail arrives → we find the user by email or ID in Admin → fix verification or plan if needed. A user regrets logging a bad trade → open it, edit, or delete in two clicks. None of this is glamorous. It is the difference between a demo and something you can operate.

Help Pages, German UI, Branded Mail, and Turning the Live Switch

The last week was less about one headline feature and more about the long list of things a product needs before you can honestly call it live. A proper help section, a second language, transactional emails that look like they belong to the app, and the slightly nerve-wracking moment of flipping Stripe and SMTP from test to production.

A help section that actually explains the product

Logged-in users had no obvious way back to documentation — the landing page menu disappeared after sign-in. Help now lives in the sidebar between Insights and Settings, and the public site has the same guide at fxledger.de. Eleven sections, a table of contents, ten FAQ entries, and room for screenshots.

We captured twenty UI screenshots ourselves — sign-in, dashboard, trade form, filters, insights by plan tier, password reset, CSV export. Hyper dark theme, 1440×900, test accounts with fake addresses so nothing personal leaks into the repo. Writing the copy forced useful clarity: if you cannot explain a feature in two sentences, the UI probably still needs work.

On screenshot lightboxes: Clicking a help image should enlarge it to about 90% of the reading pane — the area right of the sidebar — with a blurred backdrop. Not 90% of the narrow text column. Not the full viewport over the menu. We got that wrong twice before measuring the pane with getBoundingClientRect() and rendering the overlay through a portal. The third version finally behaved.

German — all of it

Early internationalisation was partial: profile fields in German, everything else in English. That is worse than no translation — it signals an unfinished product. We moved all UI strings into a shared catalogue (~500 keys), added a language switcher in Settings and Profile, and later browser-language detection on first visit (de-DE → German, otherwise English). Legal document bodies stay English for now; the routes and chrome around them are translated.

Emails that look like FxLedger

Password reset and email verification used to arrive as plain, almost anonymous HTML. They now share a dark Hyper-branded template — gradient accent, logo, six-digit code formatted as 123 456, plain-text fallback for clients that strip HTML. Same layout works for trial reminders. Brevo sends them; the from-address is noreply@fxledger.de.

Flipping TEST_MODE off

Development had been comfortable: register without verifying email, password reset codes logged to the console, rate limiting disabled. Turning that off is a checklist, not a single env var. TEST_MODE=false enables real verification. STRIPE_TEST_MODE=false is separate — subscriptions use live keys. NODE_ENV=production must reach the backend container or session cookies miss the secure flag. SMTP credentials, JWT secret, webhook URL in the Stripe dashboard — all of it has to line up before the first real user registers.

Live check: Backend log shows [Stripe] Mode: LIVE ✓, no TEST_MODE warning, NODE_ENV=production. Registration sends a verification code. Password reset sends mail without exposing a dev code in the API response. Still need to run a small real-card checkout before calling payments fully verified — a one-euro test price in Stripe works for that without committing to a monthly plan.

Small fixes that matter on real devices

The landing page used overflow: hidden on a full-viewport shell. On short laptop screens the footer — Privacy, Terms, imprint links — sat below the fold with no way to scroll. One line change: let the page scroll. Sounds trivial until a user on a 768px-tall display cannot reach your legal links.

Routing, Security, Backups, and an AI Board Room

A dense few weeks. No single big launch — instead, a long list of things that needed to exist before the product feels serious. Routing cleanup, security hardening, GDPR documentation, automated backups, and an internal tool that's become genuinely useful for thinking about the product.

The landing page finally has its own address

Until recently, fxledger.de showed the app directly — you'd land on the login screen without context. Now fxledger.de serves the landing page and fxledger.de/app is the actual application. It sounds simple, but threading it through a React single-page app and an Nginx config without breaking anything took more care than expected. The URL structure now matches how a real product should behave.

Security that should have been there from day one

Two things got added that are table stakes for any product handling user data. First: rate limiting on login and registration — the kind that stops someone from trying ten thousand passwords in a minute. Second: an audit log. Every login attempt, password change, trade action, and account deletion now gets written to a database table with a timestamp and IP address. After 90 days, entries move to an archive rather than disappearing — useful if you ever need to understand what happened and when.

On the IP address: The first entry in our audit log showed a Docker internal IP instead of the user's real address. The request was passing through two layers of nginx — one on the host, one inside a container — and the inner one was overwriting the header with its own internal address. A one-line fix, but it's the kind of thing you only notice once you look at real data.

Free plan now has a real limit

The free tier now enforces 200 trades. Hit the limit and the "Log a trade" button becomes an "Upgrade" button that takes you to the pricing page — where the buttons for plans you've already paid for are correctly disabled. Small details, but they matter when the first paying customers arrive.

GDPR documentation

A Löschkonzept and a Verzeichnis der Verarbeitungstätigkeiten — the two internal documents required under German data protection law. Neither goes on the website; both need to exist and reflect the actual system. Writing them from scratch against the real database schema and codebase took an afternoon, but at least they're accurate rather than generic. The privacy policy was also updated to remove a reference to an AI provider that no longer applies.

Backups that actually run

There was no automatic database backup. This is the kind of thing that feels fine until it isn't. Now there's a daily export of the database and the entire application directory, stored on the server and pulled nightly to a local machine via rsync. 60 days of database history, 30 days of file history. The setup is documented in enough detail that restoring from a backup doesn't require remembering how anything works.

An AI board room for product decisions

This one is harder to describe without sounding strange. There's an internal tool — only accessible to us, password-protected — where a CFO, CIO, CMO, CSO, and CPO can be brought into a discussion about any product question. You give them context, they respond in character, and the conversation gets saved for later. It started as an experiment and has become a genuine part of how we think through decisions. The CSO pushed back on the Anthropic entry in the privacy policy. The CPO asked what problem we were actually solving before any feature discussion. It's not a replacement for talking to real people, but it's a surprisingly useful way to pressure-test an idea before committing to it.

Stripe, Trials, and the First Real Subscription

Today we completed the full payment integration. Every new FxLedger account now starts with a 14-day Premium trial — full access, no credit card required. After that, users choose a plan or move to the free tier. It sounds simple. Getting there was not.

The trial design decision

We went back and forth on whether to gate features from day one or give everyone a generous trial. The answer was obvious once we said it out loud: if your product is good, let people experience it fully before asking for money. 14 days of Premium, then a clean downgrade to Free unless they upgrade. No dark patterns, no surprise charges.

Stripe Checkout — not Elements

Stripe offers three ways to accept payments. We chose their hosted Checkout page — users leave our app briefly, pay on Stripe's interface, and return. This means Stripe handles all the EU payment law complexity: Strong Customer Authentication, VAT, Klarna, the works. We don't store a single card number. The tradeoff is slightly less control over the UI, which we were happy to make.

Founding Member pricing: We enabled Stripe's promo code feature, so users can enter FOUNDING30 at checkout for 30% off. The code expires 30 June — same as the countdown on the landing page.

Live vs. Test — two separate worlds

Stripe separates live and test environments completely. Products, prices, webhooks, customers — none of it crosses over. We learned this the hard way when our test payments kept failing with "price not found in test mode." We had copied the live price IDs into the test config. One hour of confusion, five minutes to fix.

The environment setup now has a single switch in the server config: flip STRIPE_TEST_MODE to toggle between test and live keys. Useful for verifying behaviour without charging anyone real money — especially important before going public.

The webhook that wasn't

Webhooks are how Stripe tells your server "the payment went through." We set one up, but it wasn't firing. After checking logs and scratching our heads, the answer was embarrassingly simple: we had created the webhook in the live Stripe dashboard while running in test mode. Test events only go to test webhooks. Two separate webhook registrations later, everything worked.

First successful test payment: Clicked "Premium →", entered the Stripe test card, returned to the app. The trial banner vanished. Subscription section in the profile showed "Premium" in a gradient pill. The webhook had fired, the database had updated, the frontend had reloaded. Everything in sequence, as designed.

Building the Email Stack — Without Running a Mail Server

Every real product needs email. Verification codes, password resets, trial reminders — none of it works without a reliable way to send and receive. The question was how to do this without spending hours configuring something that would probably end up in spam anyway.

Why not a self-hosted mail server?

Running your own mail server sounds appealing — full control, no dependency on third parties. In practice it's one of the most time-consuming things you can do in infrastructure. New server IP addresses have no reputation, which means Gmail and Outlook will silently drop your messages for weeks until the address "warms up." We decided this was not the hill to die on.

Two tools for two jobs

The right answer was to separate sending from receiving. For sending — the verification codes and trial reminders that the app fires automatically — we use Brevo (formerly Sendinblue), a French company with EU servers, a generous free tier, and excellent deliverability. For receiving — replies to contactme@fxledger.de — we added the domain to an existing mailbox.org account. German infrastructure, ISO-certified, clean GDPR story.

The DNS adventure: Connecting a custom domain to email means setting MX records (where to receive), SPF (who can send), DKIM (signature verification), and DMARC (policy for failures). We ended up with two sets of DKIM records — one for mailbox.org, one for Brevo. It took an afternoon but the result is a professional email stack that costs less than €4/month combined.

The "do we even use cookies?" moment

While we had the privacy policy open to add the email section, we noticed it said the app uses "technically necessary cookies to maintain your login session." The app was actually using browser localStorage for that — not cookies at all. So we fixed the implementation to match the legal text, not the other way around. The session is now stored as a secure, HttpOnly cookie that JavaScript can't read — which is meaningfully more secure anyway.

Protecting the contact address

German law requires a reachable contact address in the legal notice. We publish it as contactme [at] fxledger.de rather than a clickable link — readable by humans, less interesting to address-harvesting bots. Not bulletproof, but it reduces the noise.

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 server migration

Moving from the old server to the new one was mostly smooth. Database dump, file backups, configuration transfer, restore on the new machine. The only surprise: some application data was stored inside a Docker volume rather than a regular directory. You can't back it up the obvious way — you have to know where Docker actually keeps it. A good reminder that "I know where my data is" needs to be actively verified, not assumed.

Let's Encrypt and the IPv6 problem

Getting an SSL certificate from Let's Encrypt requires proving you control the domain. Certbot kept failing with a "file not found" error — baffling, because the file was clearly there when we checked. The error came from an IPv6 address, not an IPv4 one.

The culprit: The DNS had an IPv6 record pointing to the old server. The new server doesn't have a public IPv6 address at all. So Let's Encrypt was connecting to the old server — which had no challenge file — and failing. We had the right file in the right place; it was being checked at the wrong address.

Deleting the IPv6 DNS record fixed it immediately. Certbot issued the certificate in seconds. The lesson: always check that your DNS records actually point where you think they do. This is easy to verify and easy to forget.

There was also a secondary issue where the web server's default configuration was intercepting requests before our domain configuration got a chance. Both problems were easy fixes once diagnosed.

Result: https://fxledger.de is live, HTTPS certificate valid until August, auto-renewal configured. www redirects to the apex domain. The landing page loads cleanly.

The Landing Page, a Countdown, and a Waitlist

With the app working, the next question was: how do people even find it? There was no public page — new visitors landed directly on a login screen. That needed to change before we could talk about a Founding Member offer.

The flow

We added a user journey that makes sense for a product people haven't heard of yet: a GDPR consent screen first (you must make a choice before anything loads), then the landing page, then the option to sign up or sign in. Clean, intentional, legally sound.

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 completely true.

Copy decision: "Your trades. Your data. Your rules." came naturally — it captures the core value proposition (ownership, privacy, control) in three short phrases. The italic accent on "Your data." gives it typographic weight without requiring any explanation.

The waitlist

We built a simple opt-in form: email address, a GDPR consent checkbox with explicit text, and a submit button. No newsletter platform — just our own database. The consent text is careful: "I agree that FxLedger may contact me... I can withdraw this consent at any time." No pre-checked boxes, no dark patterns, no ambiguity about what you're signing up for.

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

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

Round 1: The missing account balance

Drawdown was being calculated as a percentage of the trading P&L peak — not as a percentage of the actual account balance. If the equity curve peaked at +$800 then dipped to +$31, that looks like −96% relative to the peak. Mathematically valid in isolation. Practically meaningless without context. The fix was to calculate drawdown relative to the real starting capital, so the number means what a trader would actually expect it to mean.

Round 2: The data was being processed backwards

Fixing the balance barely changed the number. Closer inspection revealed something more fundamental: the trades were arriving in the wrong order for the calculation. The newest trades were at the front of the list, but the drawdown calculation was processing them as if they were oldest-first — effectively running the clock backwards through trading history. Peak and trough were being detected in the wrong sequence, producing nonsense.

Same bug, three places: Current win streak, all-time best win streak, and the drawdown calculation all had the same ordering problem. All three were fixed together once the root cause was understood.

Round 3: The guidance gap

Even with correct math, showing a drawdown percentage to a user who hasn't set their account balance is misleading — the denominator is wrong. Solution: if no balance is configured, the stat simply says "set balance in settings" in red. A small UX touch that nudges the user towards a meaningful number rather than a confusing one.

The lesson: time-series calculations need explicit, tested assumptions about data ordering. "Newest first" vs. "oldest first" is invisible until the numbers look obviously wrong.

Load Testing: What Breaks at 100 Concurrent Users

Before opening registration, we wanted to know: what actually breaks under real load? We ran a load test simulating 100 simultaneous users, each registering, logging in, creating trades, and fetching data. The first run was humbling: about a fifth of all requests failed, and the slowest responses took over 20 seconds.

The first hypothesis was wrong

The obvious suspect was password hashing — it's an intentionally slow operation. We'd configured it to be quite conservative. Tuning it helped a little, but not nearly enough to explain the failures. The real problem turned out to be elsewhere.

The actual bottleneck

The database connection pool had a default limit of 10 simultaneous connections. With 100 concurrent users, 90 of them were sitting in a queue waiting for a database slot to open up. Every single request that touched the database was delayed. Raising the limit to 50 made an immediate and dramatic difference.

After four test runs: Zero failures. 100% success rate across registration, trade creation, and data reads. All response times within acceptable thresholds.

A bug in the test script itself

Between the second and final run, we had a confusing spike of "username already taken" errors that appeared randomly. The cause was in our test script, not the application: we were generating usernames based on the current timestamp, so when 100 users ran in the same second they all tried to register with identical names. The fix was to use the test framework's built-in virtual user and iteration counters instead — unique by definition, no collisions possible.

Load testing early is worth the time. The connection pool problem would have been completely invisible in manual testing and potentially catastrophic on a busy launch day.

The Foundation: Stack, Four Themes, and First Trades

Every product starts with a stack decision that you'll live with for a long time. For FxLedger: a React frontend, a Node.js backend, PostgreSQL for storage, and Docker for packaging. Deployed on Hetzner — a German cloud provider — because where the data lives matters for GDPR and for the product's values.

Four themes from day one

One early decision that proved its worth: design the visual system as a proper matrix from the beginning. Two design families — Linen and Hyper — each available in light and dark mode, giving four distinct themes. Every colour, font, and shadow lives in a single theme object that gets passed through the whole interface.

Linen is warm and editorial — inspired by financial newspapers and printed matter. Newsreader serif headings, earthy neutrals, muted greens and reds. Hyper is a glass-morphism experience with violet-to-cyan gradients, glows, and depth. Completely different in character, but built on exactly the same component structure. Switching themes requires no changes to the UI code at all.

A small insight about fonts: Hyper uses the same sans-serif font for everything — including slots where other themes would use a serif. This means components written for Linen automatically adapt to Hyper's aesthetic without any special cases. The font choice becomes part of the theme, not the component.

Schema evolution

The database schema changed meaningfully in the first weeks — columns renamed, new settings added, the theme system redesigned from a single field to two separate dimensions. All of this was managed through idempotent migrations that run automatically on startup. Safe to deploy at any time, safe to run twice. No manual database operations required.

What we'd do differently

Navigation is handled entirely through application state rather than URL paths. This made the early development faster, but it came back to bite us with legal pages: you can't link directly to the Privacy Policy, and the browser's back button doesn't behave as expected. A known trade-off we'll address as the product matures.