<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Juha Wilppu]]></title><description><![CDATA[Juha Wilppu]]></description><link>https://blog.juhawilppu.com</link><generator>RSS for Node</generator><lastBuildDate>Thu, 16 Apr 2026 13:28:38 GMT</lastBuildDate><atom:link href="https://blog.juhawilppu.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My side project: 8 years in production, €13M invoiced]]></title><description><![CDATA[In early 2017, I launched a side project called Siikli. It’s a simple, mission-critical ERP that has been used to invoice over €13M. (To clarify: That figure is the amount invoiced by my users, not my revenue.)
How it started
In 2016, a friend asked ...]]></description><link>https://blog.juhawilppu.com/my-side-project-8-years-in-production-13m-invoiced</link><guid isPermaLink="true">https://blog.juhawilppu.com/my-side-project-8-years-in-production-13m-invoiced</guid><category><![CDATA[software development]]></category><dc:creator><![CDATA[Juha Wilppu]]></dc:creator><pubDate>Wed, 17 Dec 2025 19:36:46 GMT</pubDate><content:encoded><![CDATA[<p>In early 2017, I launched a side project called Siikli. It’s a simple, mission-critical ERP that has been used to invoice over €13M. (To clarify: That figure is the amount invoiced by my users, not my revenue.)</p>
<h3 id="heading-how-it-started">How it started</h3>
<p>In 2016, a friend asked for my help with invoicing. They were invoicing manually using Excel and wanted me to improve their Excel setup. I suggested dropping Excel and built a small ERP prototype for orders and invoices. It was enough to convince them that this was the right approach.</p>
<p>Soon, a lot of new feature requests and ideas started to accumulate. I insisted that they start using the application in their daily work to clarify what they actually need to complete the full end-to-end process — and only after that we’ll look at other ideas.</p>
<p>I launched Siikli in early 2017. We quickly realized we were missing some core functionality and a lot of the nice-to-haves were eventually forgotten.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1765915282947/2ea5d40f-1abe-4f0f-9c3c-63ba4f44fbf3.png" alt="Siikli order listing view. It also allows bulk printing of waybills (kuormakirjat)." class="image--center mx-auto" /></p>
<p>Siikli order listing view. Used to manage orders and bulk-print waybills (kuormakirjat).</p>
<h3 id="heading-reliability-learned-on-a-saturday-morning">Reliability learned on a Saturday morning</h3>
<p>One Saturday morning in 2017, I woke up to multiple WhatsApp messages: Siikli was down and it was blocking their shipments since there was no way to print waybills for truck deliveries.</p>
<p>I quickly noticed that Tomcat had crashed. No logs, no errors, it just wasn’t running anymore. I restarted it, and they were able to continue deliveries. Clearly, I never wanted to start my Saturday like that anymore. I added a cron job that checked every five minutes that Tomcat was still running and restarted it if needed.</p>
<p>A few months later, I received similar messages again: This time MySQL had crashed. I added a similar script for it.</p>
<p>After two stressful situations, I really started to prioritize reliability. I changed daily backups to hourly backups and started copying them to two different countries. I added a notification emails if the application stopped responding.</p>
<p>As the system became more critical, I started to become more and more worried about security. I limited SSH access to my own IP and started hiding version numbers. I got worried about the access logs showing huge amount of crawlers looking for <code>.env</code> files and other vulnerabilities, so I added a firewall to only allow traffic from Finland.</p>
<p>These were pretty basic things, but improved the quality of my sleep.</p>
<h3 id="heading-ownership-when-youre-the-solo-dev">Ownership when you’re the solo dev</h3>
<p>I already felt that I had been taking a lot of ownership at my day work, but Siikli felt different.</p>
<p>When something failed, there was no one else to blame. I realized how easy it would’ve been to blame someone else for forcing me to rush to keep a deadline, which led to mistakes. No, all mistakes were mine.</p>
<h2 id="heading-what-would-i-do-differently">What would I do differently</h2>
<p>Siikli never became a major success. The mental overhead of maintaining a mission-critical application as a solo dev has been too taxing compared to the income I’ve made. The security, backups and reliability were all bigger headaches than I had anticipated. Even though I’ve not done any coding for Siikli in 3 years, I feel the stress is like a sub-process somewhere in my brain, taking 1-2 % of mental capacity.</p>
<p>After 8 years, I’m now considering either growing the app or discontinuing it. Even discontinuing an app is not easy when your users rely on it.</p>
]]></content:encoded></item><item><title><![CDATA[Zero-downtime region migration (from US to Europe)]]></title><description><![CDATA[Recently, I migrated our production servers from the US to Europe with zero downtime. The technical execution was straightforward with our plan, but caused one issue.
Why we migrated
We originally launched our servers in the US with high expectations...]]></description><link>https://blog.juhawilppu.com/zero-downtime-region-migration</link><guid isPermaLink="true">https://blog.juhawilppu.com/zero-downtime-region-migration</guid><category><![CDATA[AWS]]></category><dc:creator><![CDATA[Juha Wilppu]]></dc:creator><pubDate>Thu, 11 Dec 2025 22:00:00 GMT</pubDate><content:encoded><![CDATA[<p>Recently, I migrated our production servers from the US to Europe with zero downtime. The technical execution was straightforward with our plan, but caused one issue.</p>
<h2 id="heading-why-we-migrated">Why we migrated</h2>
<p>We originally launched our servers in the US with high expectations of usage from there, but reality went the other way. We started getting users from Northern Europe, and to optimize latency and meet GDPR data residency requirements, we decided to move the whole infrastructure to EU.</p>
<h2 id="heading-choosing-the-migration-strategy">Choosing the migration strategy</h2>
<p>Most migrations require choosing between consistency and uptime. We chose to prioritize uptime.</p>
<p>We took a calculated risk: we would snapshot the US database while the system was running, copy the snapshot to Europe, and restore it there. This meant accepting a 60-minute window of data loss for anything written after the snapshot.</p>
<p>For many applications, this would be an absurd idea. For us, it made sense. Our users receive a confirmation email, and most of them won’t even notice or use their account. By migrating during the night, we estimated that the data loss would only affect 1-2 users, and they will unlikely need their accounts. Keeping the system reachable and functioning throughout the process was more important.</p>
<h2 id="heading-the-actual-migration">The actual migration</h2>
<p>I started by duplicating all resources: two databases, two Redis instances, two ECS services — two of everything. I basically ran a warm standby in the new region.</p>
<p>I had never run a multi-region service before, and it was interesting to see the Terraform pieces locking into place. Terraform modules can be used multiple times with different AWS providers (from different regions). This immediately exposed a design mistake in my Terraform code: I had bundled global resources (like IAM roles) into regional modules. I had to move 37 resources to support this use case.</p>
<p>At 22:00, with all infrastructure and data ready, I switched CloudFront origin from the US to Europe. I had practiced this in staging, but it still felt like taking a leap of faith.</p>
<h2 id="heading-the-edge-case-i-missed">The edge case I missed</h2>
<p>Traffic moved immediately and everything seemed fine. But a few seconds later, I saw an error in the logs: <code>User ID does not exist</code>.</p>
<p>I was too focused on complex issues so I had overlooked a simple issue: if a user created an account in the US database and we switched traffic to the 60-minute-old copy, their newly created account would vanish in the middle of the process. Fortunately, we had planned our client-side to handle unexpected errors. It kept a copy of the form data, and two minutes later I saw the user completing their process.</p>
<h2 id="heading-takeaway">Takeaway</h2>
<p>The infrastructure work was a great exercise in multi-region Terraform. We accomplished what we needed with a simple approach.</p>
]]></content:encoded></item><item><title><![CDATA[Multiplayer math game I built in 2013]]></title><description><![CDATA[Back in 2013, I tried to reinvent how kids learn math by building a competitive online multiplayer game. I tested it in a real classroom with 20 students. Here’s how it went.
The idea
I believed that competition would motivate students. Kids who norm...]]></description><link>https://blog.juhawilppu.com/multiplayer-math-game-i-built-in-2013</link><guid isPermaLink="true">https://blog.juhawilppu.com/multiplayer-math-game-i-built-in-2013</guid><category><![CDATA[GameDev]]></category><category><![CDATA[education]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Juha Wilppu]]></dc:creator><pubDate>Wed, 29 Oct 2025 18:46:50 GMT</pubDate><content:encoded><![CDATA[<p>Back in 2013, I tried to reinvent how kids learn math by building a competitive online multiplayer game. I tested it in a real classroom with 20 students. Here’s how it went.</p>
<h2 id="heading-the-idea">The idea</h2>
<p>I believed that competition would motivate students. Kids who normally weren’t paying attention in class would be more eager to learn math when it was used in a competitive game. I borrowed ideas from Counter-Strike: teams, flag capturing and scoreboards. Then I used them for math learning.</p>
<h2 id="heading-the-implementation">The implementation</h2>
<p>I forked <a target="_blank" href="https://github.com/mozilla/BrowserQuest">BrowserQuest</a>, an open-source game built by Mozilla. I replaced sword fights with math battles: both players saw the same question, like 10×3, and the first player to answer correctly dealt damage. The battle ended when one player ran out of health.</p>
<p>I divided players into red and blue teams, added a lobby, respawning, a minimap, scoreboards, and a custom map. I even built chat functionality to make it more engaging.</p>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXeyHrKUx-mPzEGSCuMn9t6RGGH2_RoznCb8XsMos1ARxuP6BFqkipRF7CKOK6PhnQm4wrADon49t16-mMQvhkXxZCnrPs7_ykqA1WJnhYOwiMMqqpeozJqGWch1cmlKtAxJVOqXeg?key=4I_uS5t4LfUP9vhI4aXajg" alt /></p>
<p><img src="https://lh7-rt.googleusercontent.com/docsz/AD_4nXeLO4tpFUnisAWyRXFirHZ6tR5_wP6Ava1dPy7b3h8mYPe4HOE3oTj4AGvXoGa0HnBjuezwJD7qaFJbeU5E0-JirArmHXnTns-8E3mXho0gkVIZvtTN2LijWfLnOhMwgQQ9a7GFGw?key=4I_uS5t4LfUP9vhI4aXajg" alt /></p>
<h2 id="heading-the-tech-stack">The tech stack</h2>
<p>The game was built with Node.js v0.8, WebSockets and Canvas API.</p>
<p>The multiplayer aspect was the biggest challenge. Latency and async messaging meant that each player saw the game events differently. Since there wasn’t a centralized, authoritative server for the game logic, the server had to reconcile conflicting client messages into a shared state. You never knew which message to trust.</p>
<h2 id="heading-the-classroom-test">The classroom test</h2>
<p>I ran the experiment at an elementary school in Turku, Finland, with 7-year old students. The students were split into two groups: a group who played the game and a control group.</p>
<p>Students played the game very differently than I expected: weaker students often gave up during math battles. They just did nothing and waited to lose. Because the question refreshed for <em>both</em> players as soon as one answered, slower students realized they had no chance and simply stopped trying.</p>
<p>A lot of features went unused. Minimap, chat, leveling up, bots had no use. 7-year-olds didn’t really understand flag capture and didn’t coordinate attacks in a team.</p>
<p>I had spent weeks, even months, building features that had no value. Something much simpler would’ve been better for learning and faster to build.</p>
<h2 id="heading-the-results">The results</h2>
<p>The group that played the game improved their quiz result by <strong>56%</strong>. The control group, sitting in a regular math class, improved by <strong>87%</strong>.</p>
<p><strong>Regular math class was significantly more effective than my game.</strong></p>
<h2 id="heading-what-i-learned">What I learned</h2>
<p>I still think the project was cool, and there’s something respectable about committing to a crazy idea. But I should have simplified a lot.</p>
<p>The core idea was way too stressful. Forced competition isn’t motivation since half of the class is below average. Everyone needs to learn at their own pace.</p>
<h2 id="heading-why-it-still-matters">Why it still matters</h2>
<p>Y Combinator’s guide <a target="_blank" href="https://www.ycombinator.com/library/8g-how-to-get-startup-ideas">How to get startup ideas</a> has a concept that fits perfectly here. It warns about “solutions in search of problems”. Taking an idea that worked, but building it for another purpose. X but for Y.</p>
<p>That’s exactly what I built: Counter-Strike but for math. I was so busy solving technical problems that I never stopped to ask if I was solving the real problem — learning math. Since then, I’ve learned to start from the problem.</p>
]]></content:encoded></item><item><title><![CDATA[IP whitelisting mitigated a critical security issue]]></title><description><![CDATA[IP whitelisting is old-school. But it’s also a simple way to add a surprisingly strong second layer of defence. Here’s how it saved us during a security audit.
The mistake: Trusting the Host header
We had a vulnerability in the “Forgot password” feat...]]></description><link>https://blog.juhawilppu.com/how-ip-whitelisting-mitigated-a-critical-security-issue</link><guid isPermaLink="true">https://blog.juhawilppu.com/how-ip-whitelisting-mitigated-a-critical-security-issue</guid><category><![CDATA[Security]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[Juha Wilppu]]></dc:creator><pubDate>Sun, 26 Oct 2025 08:44:36 GMT</pubDate><content:encoded><![CDATA[<p>IP whitelisting is old-school. But it’s also a simple way to add a surprisingly strong second layer of defence. Here’s how it saved us during a security audit.</p>
<h2 id="heading-the-mistake-trusting-the-host-header"><strong>The mistake: Trusting the Host header</strong></h2>
<p>We had a vulnerability in the “Forgot password” feature for admin users. When a user requested a password reset link, the application used the HTTP Host header to determine which domain (staging or production) to use in the link that was emailed to the user.</p>
<p>This is a classic mistake: you should never trust the Host header as it can be forged by an attacker.</p>
<p>By forging the header, an attacker could generate a reset link pointing to their own domain:<br /><code>https://evil-domain.io/api/auth/password-reset?token=...</code>. Since the email only showed “Reset your password” button, it wasn’t obvious that the link had been tampered with. If the admin clicked it, the secret token was sent to the attacker.</p>
<h2 id="heading-the-second-layer-to-the-rescue"><strong>The second layer to the rescue</strong></h2>
<p>We had implemented a “Defense in Depth” strategy and all admin endpoints (<code>/api/admin/*</code>) were protected by IP whitelisting. Only traffic coming from our office VPN was allowed through.</p>
<p>The attacker could have successfully hijacked the token and reset the password, but they still couldn’t perform any admin actions. Their IP wasn’t on the whitelist.</p>
<p>During the security audit, the issue was downgraded from <em>critical</em> to <em>high</em> because of this safeguard. It was still bad, but contained.</p>
<h2 id="heading-but-ips-can-be-spoofed"><strong>“But IPs can be spoofed”</strong></h2>
<p>Technically, yes, but spoofing is practically useless in this situation:</p>
<ul>
<li><p><strong>The attacker doesn’t know the correct IP.</strong> Guessing a specific IPv4 address is impractical, and our rate-limit makes it almost impossible.</p>
</li>
<li><p><strong>Spoofing breaks the response path.</strong> TCP/IP protocol works so that the server’s response goes to the spoofed IP address, not back to the attacker. So while they could send blind <code>POST</code> and <code>DELETE</code> requests without knowing if they succeeded, they wouldn’t receive any sensitive data back.</p>
</li>
</ul>
<p><strong>Simply: the data can’t leak</strong>. That eliminates a massive attack vector.</p>
]]></content:encoded></item><item><title><![CDATA[Best-in-class backups in AWS]]></title><description><![CDATA[Many years ago, I lost production data that I couldn’t recover. I remember looking at the screen in disbelief. The mix of regret and helplessness is something I never want to experience again. Today, I use a “paranoid” backup strategy built on immuta...]]></description><link>https://blog.juhawilppu.com/best-in-class-backups-in-aws</link><guid isPermaLink="true">https://blog.juhawilppu.com/best-in-class-backups-in-aws</guid><category><![CDATA[AWS]]></category><category><![CDATA[Devops]]></category><category><![CDATA[rds]]></category><dc:creator><![CDATA[Juha Wilppu]]></dc:creator><pubDate>Tue, 05 Aug 2025 20:03:03 GMT</pubDate><content:encoded><![CDATA[<p>Many years ago, I lost production data that I couldn’t recover. I remember looking at the screen in disbelief. The mix of regret and helplessness is something I never want to experience again. Today, I use a “paranoid” backup strategy built on immutability.</p>
<h3 id="heading-you-need-aws-backup-not-just-rds-backups">You need AWS Backup, not just RDS backups</h3>
<p>Standard RDS backups are tied to the instance lifecycle. If the instance is deleted, you risk losing its automatic snapshots.</p>
<p>You need to use AWS Backup to decouple backups from the instance.</p>
<h3 id="heading-compliance-mode-no-one-can-delete-the-backups">C<strong>ompliance mode: no one can delete the backups</strong></h3>
<p>We’ve locked our AWS Backup Vault in compliance mode. This makes the backups immutable. They cannot be deleted: not by a tired developer, a disgruntled employee, and even an attacker with full administrator access. This is a definitive defence against ransomware.</p>
<p>The downside is that you can’t delete the backups even if you want to. If you accidentally generate terabytes of data, you have to pay for it until the retention period (35 days for us) expires. Even AWS support cannot bypass this. We accept that risk. I would rather risk a high bill than business continuity.</p>
<h3 id="heading-hourly-backups-and-rpo"><strong>Hourly backups and RPO</strong></h3>
<p>We run backups every hour, the shortest interval AWS Backup supports. This gives us a Recovery Point Objective (RPO) of one hour. In a worst-case disaster, we lose 60 minutes of data.</p>
<h3 id="heading-dont-lose-a-database-by-accident"><strong>Don’t lose a database by accident</strong></h3>
<p>“Deletion Protection” should be enabled on every production RDS instance. It’s a numbers game: With enough time and enough developers, the “wrong environment” mistake will eventually happen. I’ve seen it.</p>
<h3 id="heading-disaster-proofing-with-cross-region-replication"><strong>Disaster-proofing with cross-region replication</strong></h3>
<p>We replicate our backups to two regions: Stockholm (<code>eu-north-1</code>) and Frankfurt (<code>eu-central-1</code>). If an entire region goes down, our data remains safe and accessible.</p>
<p>For absolutely certainty, 3-2-1 strategy would be the best: 3 copies (original +2 regions), 2 different types (RDS + Backup Vault) and 1 offsite (outside of AWS). We haven’t implemented the offsite copy yet, but cross-region copy covers our current risk profile.</p>
<h3 id="heading-cost-breakdown"><strong>Cost breakdown</strong></h3>
<p>Let’s assume the database has 1 GB of data with moderate churn.</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Item</td><td>Cost/month</td></tr>
</thead>
<tbody>
<tr>
<td>Hourly backups (Stockholm)</td><td>$36.00</td></tr>
<tr>
<td>Hourly backups (Frankfurt)</td><td>$36.00</td></tr>
<tr>
<td>Cross-region data transfer</td><td>$14.40</td></tr>
<tr>
<td>Total</td><td>$86.40</td></tr>
</tbody>
</table>
</div><h3 id="heading-restoring-backups">Restoring backups</h3>
<p>Backups are just the beginning. Continously monitor that backup jobs are actually succeeding and test that you can restore the data.</p>
<p>Also, don’t forget to back up <em>everything</em>: secrets, configuration files, and all forms of data (like S3).</p>
]]></content:encoded></item></channel></rss>