<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Erik</title>
    <description>The latest articles on Forem by Erik (@erik_df43be0db3da3f32f636).</description>
    <link>https://forem.com/erik_df43be0db3da3f32f636</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3688443%2Fb0757754-c836-416c-8c04-8bb85faf4a9e.jpeg</url>
      <title>Forem: Erik</title>
      <link>https://forem.com/erik_df43be0db3da3f32f636</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/erik_df43be0db3da3f32f636"/>
    <language>en</language>
    <item>
      <title>Day 8: Designing the API Contract First</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Thu, 08 Jan 2026 13:11:44 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-8-designing-the-api-contract-first-3ifd</link>
      <guid>https://forem.com/allscreenshots/day-8-designing-the-api-contract-first-3ifd</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 8 of 30. Our week 2 begins. Today we define the interface before building the implementation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One of the most challenging things to change when developing an application like ours, is our API contract. Any change to the API might have a chance of affecting one or more clients, so once developers integrate with your API, changing the API is painful, so our API requires a bit of thinking.&lt;/p&gt;

&lt;p&gt;So, let's design something we won't regret (too much...).&lt;/p&gt;

&lt;h2&gt;
  
  
  API design principles we're following
&lt;/h2&gt;

&lt;p&gt;There are different technologies and different approaches for designing remote APIs. One of the most common approaches would be a REST API over HTTP, but even when using REST, there are different maturity levels of defining APIs.&lt;/p&gt;

&lt;p&gt;While we value a REST API, in our case, our focus in more making an API which makes integration easier, and when it makes sense, we'll use a REST approach, but when it doesn't make sense, we're taking a conscious approach to delivering an API which still makes a lot of sense.&lt;/p&gt;

&lt;p&gt;We use the following criteria for our API:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Be predictable.&lt;/strong&gt; Follow REST conventions as much as we can. We use standard HTTP methods and status codes, and we don't want to surprise developers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Be consistent.&lt;/strong&gt; If one endpoint returns &lt;code&gt;created_at&lt;/code&gt;, all endpoints have to return &lt;code&gt;created_at&lt;/code&gt; with the same naming, same formats, everywhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Be explicit.&lt;/strong&gt; We shouldn't introduce magic. If a parameter affects behavior, we will require it explicitly rather than inferring from a context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Be versioned.&lt;/strong&gt; While maintaining multiple versions of our API is not our goal, we will start with &lt;code&gt;/api/v1/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While we hope to never need v2, and we don't really want to cater for all "what if" scenarios, the API is quite important, and having the option to add a v2 in the future could prove useful.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Return useful errors.&lt;/strong&gt; One of the most frustrating things is getting errors without a clear explanation on how to resolve the error. We had our fair share in that, so we don't just want to say "Bad Request.". Instead, we'll say what's  wrong and how to fix it,&lt;br&gt;
with a link to our documentation portal where needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual API
&lt;/h2&gt;

&lt;p&gt;We'll spare you the full spec, but here's the gist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /api/v1/screenshots&lt;/code&gt; - submit a URL, get back a job ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /api/v1/screenshots/{id}&lt;/code&gt; - check if it's done, get the image URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /api/v1/screenshots&lt;/code&gt; - list your recent screenshots&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /api/v1/usage&lt;/code&gt; - see how many screenshots you've used this month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four endpoints. That's it. We can always add more, but we can never take them away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation notes
&lt;/h2&gt;

&lt;p&gt;A few decisions that affect how we'll build this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IDs are prefixed.&lt;/strong&gt; &lt;code&gt;scr_&lt;/code&gt; for screenshots makes debugging easier. You immediately know what kind of ID you're looking at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamps are ISO 8601 UTC.&lt;/strong&gt; Always to prevent ambiguity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagination uses offset, not cursors.&lt;/strong&gt; Pagination like this is simpler to implement, and while cursor-based pagination might be better at scale, but this scale is beyond our usecase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Image URLs are signed and temporary.&lt;/strong&gt; Image URLs expire after 24 hours. This lets us clean up storage and prevents hotlinking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wrote the full API specification (this document, basically)&lt;/li&gt;
&lt;li&gt;Created request/response DTOs in Kotlin&lt;/li&gt;
&lt;li&gt;Set up validation annotations&lt;/li&gt;
&lt;li&gt;Stubbed out the controller endpoints (returning mock data)&lt;/li&gt;
&lt;li&gt;Tested with curl to verify the contract&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No actual screenshot integration yet - that comes tomorrow. But the interface is locked.&lt;/p&gt;

&lt;p&gt;When we wire up the real implementation, we will be less tempted to "just quickly change" the API shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: building the screenshot queue
&lt;/h2&gt;

&lt;p&gt;Tomorrow, day 9, we will build the real thing. Job queue in Postgres, worker processing, status updates. The contract we defined today becomes real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.amazon.com/REST-API-Design-Rulebook-Consistent/dp/1449310508?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;REST API Design Rulebook&lt;/a&gt; by &lt;strong&gt;Mark Massé&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Short, practical, opinionated. This book gives you concrete rules for designing REST APIs that don't suck.&lt;/p&gt;

&lt;p&gt;Some highlights: use nouns not verbs in URLs, use HTTP methods correctly, be consistent with naming conventions, design for extensibility without breaking changes.&lt;/p&gt;

&lt;p&gt;We don't agree with everything (the book is a bit dogmatic about HATEOAS), but as a quick reference for "what's the right way to do X in a REST API," it's invaluable.&lt;/p&gt;

&lt;p&gt;If you're designing an API and want to avoid common mistakes, read this first. It's only 100 pages.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 17 (14 + 3 today)&lt;/li&gt;
&lt;li&gt;Lines of code: ~650&lt;/li&gt;
&lt;li&gt;Monthly hosting cost: $5.50&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;API endpoints defined: 4&lt;/li&gt;
&lt;li&gt;API endpoints implemented: 0 (stubs only)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Day 7: Build in Public Week 1: What Worked, What Didn't</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Thu, 08 Jan 2026 13:09:11 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-7-build-in-public-week-1-what-worked-what-didnt-3862</link>
      <guid>https://forem.com/allscreenshots/day-7-build-in-public-week-1-what-worked-what-didnt-3862</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 7 of 30. End of week one. Time to be honest about where we are.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We've been heads-down for a week. "Shipping" every day, or at least thinking every day, and writing about our learning. &lt;br&gt;
But are we actually on track? Let's review.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we set out to do
&lt;/h2&gt;

&lt;p&gt;The goal: build a screenshot API SaaS and get our first paying customer in 30 days. Specifically, by day 15 we wanted to have something people would fine useful.&lt;/p&gt;

&lt;p&gt;That's 8 days from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually accomplished
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Day&lt;/th&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Define the project, set constraints&lt;/td&gt;
&lt;td&gt;✓ Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Choose tech stack, set up repo&lt;/td&gt;
&lt;td&gt;✓ Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Design architecture&lt;/td&gt;
&lt;td&gt;✓ Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Set up CI/CD pipeline&lt;/td&gt;
&lt;td&gt;✓ Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Configure hosting &amp;amp; storage&lt;/td&gt;
&lt;td&gt;✓ Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Get Playwright capturing screenshots&lt;/td&gt;
&lt;td&gt;✓ Done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Week 1 retro&lt;/td&gt;
&lt;td&gt;✓ (this post)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On paper, we're on track, and every planned task got done, more or less. We're sure we can make improvements on each step, but for now, we have a solid basis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually working
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The screenshot engine works.&lt;/strong&gt; We can hit an endpoint, pass a URL, and get back a PNG. It takes 2-3 seconds for most sites. That's the core product, and it functions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CI/CD is smooth.&lt;/strong&gt; Push to main, wait a few minutes, it's deployed. There are no manual steps, and the setup is simple. This has already saved us time and mental overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Costs are controlled.&lt;/strong&gt; We're under $10 per month, not counting our own hours. We can run this project for months without the stress of running out of money. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Daily writing is sustainable.&lt;/strong&gt; We were worried the dev log might feel like a chore, but doesn't. Writing clarifies thinking, and knowing we'll publish keeps us accountable. Also, we love the amount of feedback we get! The things where we're wrong (sorry!), where we can improve, where we're on the right track, or where we're missing something. We love all the feedback we get, keep it coming!  &lt;/p&gt;

&lt;h2&gt;
  
  
  What's not working (yet)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;We don't have an API yet.&lt;/strong&gt; We have a screenshot function. But there's no authentication, no rate limiting, no job queue, no user accounts. Someone can't actually use this as a service yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No landing page.&lt;/strong&gt; Besides our blog, we have currently zero web presence. If someone wanted to sign up today, they couldn't. (though, if you want, you can sign up for early access and we'll give you an API key to try out our service!)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope is not well-defined yet.&lt;/strong&gt; We have a lot of potential features in our backlog which we're keen on delivering, but we have to make sure we're keeping track of our mission, and not getting sidetracked by features that are not important to our goal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest assessment: are we on track?
&lt;/h2&gt;

&lt;p&gt;For a paying customer by day 15, we want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] User registration and login&lt;/li&gt;
&lt;li&gt;[ ] API key generation&lt;/li&gt;
&lt;li&gt;[ ] The actual REST API with auth&lt;/li&gt;
&lt;li&gt;[ ] Usage tracking&lt;/li&gt;
&lt;li&gt;[ ] A landing page explaining what this is&lt;/li&gt;
&lt;li&gt;[ ] Stripe integration for payments&lt;/li&gt;
&lt;li&gt;[ ] Documentation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a lot for 8 days, but not impossible, and if we keep our focus on our deliverables, there's a chance we can deliver all of them, albeit in a limited version, perhaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're changing for week 2
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Tighter scope.&lt;/strong&gt; Our V1 deliverable is: sign up, get the API key, capture screenshots in a sync way, see your usage, and allow payments.  Out of scope at this moment are items like PDF export, scheduled captures, and a lot more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Landing page this week.&lt;/strong&gt; Even if it's perhaps not entirely to our liking, we're keen on having a URL we can share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Talk to more people.&lt;/strong&gt; Post in communities, reach out to developers we know, ask what functionality they would find useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Let's be transparent about time and money:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time spent:&lt;/strong&gt; 14 hours across 7 days. Average of 2 hours/day. That's less than we expected - life gets in the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Money spent:&lt;/strong&gt; $4.50 (one month of Hetzner VPS).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lines of code:&lt;/strong&gt; ~450. Most of it is the Playwright integration and Docker config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revenue:&lt;/strong&gt; $0&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customers:&lt;/strong&gt; 0&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;People who know this exists:&lt;/strong&gt; Maybe 10 (friends we've mentioned it to)&lt;/p&gt;

&lt;h2&gt;
  
  
  What would success look like?
&lt;/h2&gt;

&lt;p&gt;By day 15 (end of week 2):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Landing page live&lt;/li&gt;
&lt;li&gt;API functional with auth&lt;/li&gt;
&lt;li&gt;Stripe integration working&lt;/li&gt;
&lt;li&gt;Posted in 3+ communities&lt;/li&gt;
&lt;li&gt;At least 5 beta signups&lt;/li&gt;
&lt;li&gt;Ideally: 1 paying customer (even at $5)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By day 30:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stable product&lt;/li&gt;
&lt;li&gt;Clear documentation&lt;/li&gt;
&lt;li&gt;10+ users (free or paid)&lt;/li&gt;
&lt;li&gt;At least $50 in revenue&lt;/li&gt;
&lt;li&gt;A decision: continue or sunset&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons from week 1
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure first was right.&lt;/strong&gt; Having CI/CD and hosting sorted means we can now move fast on features without deployment friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writing takes time but pays off.&lt;/strong&gt; These posts take 30-45 minutes each. That's time not coding. But they force clarity and create a record we'll value later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The boring stuff matters.&lt;/strong&gt; We spent zero time on "exciting" features like AI enhancement or visual diffs. We spent all our time on Docker, databases, and deployment. That's correct prioritization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solo building is hard.&lt;/strong&gt; No one to bounce ideas off, no one to catch mistakes, no one to share the load. We're managing, but it's harder than working with a team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: the actual API
&lt;/h2&gt;

&lt;p&gt;Week 2 starts now. Day 8 we're building the REST API contract - the endpoints, request/response formats, error handling. The interface that developers will actually use.&lt;/p&gt;

&lt;p&gt;Let's go!&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;*&lt;br&gt;
&lt;em&gt;&lt;a href="https://www.amazon.com/Lean-Startup-Entrepreneurs-Continuous-Innovation/dp/0307887898?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;The Lean Startup&lt;/a&gt;&lt;br&gt;
by Eric Ries&lt;/em&gt;*&lt;/p&gt;

&lt;p&gt;The classic. We've read it before, but week 1's retrospective reminded us of its core message: build, measure, learn, ideally as&lt;br&gt;
fast as possible.&lt;/p&gt;

&lt;p&gt;We've been building, but we haven't been measuring enough. We haven't introduced analytics yet, we have no landing page to gauge interest and we have room to improve in our marketing of our product. &lt;/p&gt;

&lt;p&gt;Ries would tell us to get something in front of potential customers immediately, even if it's embarrassing. A landing page with an email signup. A tweet asking if anyone needs this. Anything to start the feedback loop.&lt;/p&gt;

&lt;p&gt;Week 2 is about closing that loop.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 14 total&lt;/li&gt;
&lt;li&gt;Lines of code: ~450&lt;/li&gt;
&lt;li&gt;Monthly hosting cost: $5.50&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;Days until target first customer: 8&lt;/li&gt;
&lt;li&gt;Confidence level: 60%&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>I Built a CLI to Capture Website Screenshots From The Terminal</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Tue, 06 Jan 2026 14:45:07 +0000</pubDate>
      <link>https://forem.com/erik_df43be0db3da3f32f636/i-built-a-cli-to-capture-website-screenshots-from-the-terminal-1km2</link>
      <guid>https://forem.com/erik_df43be0db3da3f32f636/i-built-a-cli-to-capture-website-screenshots-from-the-terminal-1km2</guid>
      <description>&lt;h2&gt;
  
  
  The Problem That Kept Bugging Me
&lt;/h2&gt;

&lt;p&gt;As developers, we spend a lot of time in the terminal. Git, Docker, SSH, package managers - it's where we're most productive. But the moment I needed to see or screenshot a website, I'd have to break that flow entirely.&lt;/p&gt;

&lt;p&gt;The routine was often the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Need to check a site&lt;/li&gt;
&lt;li&gt;Open browser&lt;/li&gt;
&lt;li&gt;Navigate to URL&lt;/li&gt;
&lt;li&gt;Open DevTools to resize viewport (if testing responsiveness)&lt;/li&gt;
&lt;li&gt;Find a screenshot tool or use the browser's built-in one&lt;/li&gt;
&lt;li&gt;Save the file&lt;/li&gt;
&lt;li&gt;Drag it into Slack/docs/issue tracker&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For something I did multiple times a day, such as checking deploys, documenting bugs, archiving pages for clients, this friction was frustrating. I wanted &lt;code&gt;curl&lt;/code&gt; for screenshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  So I Built It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;allscreenshots&lt;/strong&gt; is a CLI tool that captures website screenshots and displays them directly in your terminal! If your terminal supports Sixel, iTerm2, or Kitty image protocols, you'll see the screenshot inline. No context switch required!&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy9kkhadk5k3yqyj0fm1w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fy9kkhadk5k3yqyj0fm1w.png" alt="Dev.to Capture in the Terminal" width="800" height="715"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Also, if it doesn't support those image protocols, there's a blocky fallback option, which is better than you may think.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots https://your-site.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The screenshot is rendered right where you're working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Rust?
&lt;/h2&gt;

&lt;p&gt;When deciding what language to pick, I had a few options. Go and Rust were my initial choices. &lt;/p&gt;

&lt;p&gt;I chose Rust for a few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fast startup time&lt;/strong&gt;  -  CLI tools should feel instant. No waiting for a runtime to spin up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single binary distribution&lt;/strong&gt;  -  &lt;code&gt;brew install&lt;/code&gt; and you're done. There are no dependencies to manage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-platform&lt;/strong&gt;  -  Works on macOS, Linux, and Windows without separate codebases*. (*in theory, only macOS is tested at this stage!)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reliability&lt;/strong&gt;  -  Memory safety without a garbage collector means fewer surprises in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actual rendering happens via a cloud API, so you don't need a local browser installation or deal with headless Chrome configuration. The Rust CLI handles authentication, image processing, and terminal rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Features Built for Developer Workflows
&lt;/h2&gt;

&lt;p&gt;The CLI comes packaged with a large amount of features&lt;/p&gt;

&lt;h3&gt;
  
  
  Device Emulation
&lt;/h3&gt;

&lt;p&gt;Are you testing responsive designs? Then you can emulate specific devices without opening DevTools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots https://mysite.com &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPhone 14"&lt;/span&gt;
allscreenshots https://mysite.com &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPad Pro"&lt;/span&gt;
allscreenshots https://mysite.com &lt;span class="nt"&gt;--width&lt;/span&gt; 1920 &lt;span class="nt"&gt;--height&lt;/span&gt; 1080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb8trn1gcjtgf0cn9s7sq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb8trn1gcjtgf0cn9s7sq.png" alt="Netflix Mobile Capture" width="800" height="688"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Full-Page Captures
&lt;/h3&gt;

&lt;p&gt;Capture the entire scrollable page, not just the viewport:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots https://mysite.com &lt;span class="nt"&gt;--full-page&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; full.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Batch Processing
&lt;/h3&gt;

&lt;p&gt;Have a list of URLs to capture? Feed them from a file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots batch urls.txt &lt;span class="nt"&gt;-o&lt;/span&gt; ./screenshots/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Perfect for visual regression testing, documentation, or archiving.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watch Mode
&lt;/h3&gt;

&lt;p&gt;Continuously capture screenshots during development:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots https://localhost:3000 &lt;span class="nt"&gt;--watch&lt;/span&gt; &lt;span class="nt"&gt;--interval&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Clean Screenshots
&lt;/h3&gt;

&lt;p&gt;Cookie banners and ads can ruin screenshots. You can Bblock them automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots https://example.com &lt;span class="nt"&gt;--block-ads&lt;/span&gt; &lt;span class="nt"&gt;--block-cookies&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Multiple Output Formats
&lt;/h3&gt;

&lt;p&gt;Export to whatever you need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;allscreenshots https://example.com &lt;span class="nt"&gt;-o&lt;/span&gt; page.png   &lt;span class="c"&gt;# PNG&lt;/span&gt;
allscreenshots https://example.com &lt;span class="nt"&gt;-o&lt;/span&gt; page.jpg   &lt;span class="c"&gt;# JPEG&lt;/span&gt;
allscreenshots https://example.com &lt;span class="nt"&gt;-o&lt;/span&gt; page.webp  &lt;span class="c"&gt;# WebP&lt;/span&gt;
allscreenshots https://example.com &lt;span class="nt"&gt;-o&lt;/span&gt; page.pdf   &lt;span class="c"&gt;# PDF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(More formats are on the way!)&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CI/CD Visual Testing&lt;/strong&gt;&lt;br&gt;
Capture screenshots after each deploy and compare them automatically. Catch visual regressions before users do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation&lt;/strong&gt;&lt;br&gt;
Generating docs that include UI screenshots? Script it instead of manually capturing and cropping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug Reports&lt;/strong&gt;&lt;br&gt;
Quickly attach a screenshot to an issue without leaving your terminal workflow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Archiving&lt;/strong&gt;&lt;br&gt;
Need to preserve how a page looked at a specific point in time? Batch capture and store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client Reporting&lt;/strong&gt;&lt;br&gt;
Capture multiple pages across device sizes for client deliverables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installation
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew tap allscreenshots/allscreenshots &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; brew &lt;span class="nb"&gt;install &lt;/span&gt;allscreenshots

&lt;span class="c"&gt;# Or download binaries from GitHub releases&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Quick Start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Capture and display inline&lt;/span&gt;
allscreenshots https://github.com

&lt;span class="c"&gt;# Save to file&lt;/span&gt;
allscreenshots https://github.com &lt;span class="nt"&gt;-o&lt;/span&gt; github.png

&lt;span class="c"&gt;# Full page, iPhone viewport, no cookie banners&lt;/span&gt;
allscreenshots https://example.com &lt;span class="nt"&gt;--full-page&lt;/span&gt; &lt;span class="nt"&gt;--device&lt;/span&gt; &lt;span class="s2"&gt;"iPhone 14"&lt;/span&gt; &lt;span class="nt"&gt;--block-cookies&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; mobile.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Website&lt;/strong&gt;: &lt;a href="https://screenshots.sh" rel="noopener noreferrer"&gt;https://screenshots.sh&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation&lt;/strong&gt;: &lt;a href="https://screenshots.sh/docs" rel="noopener noreferrer"&gt;https://screenshots.sh/docs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub&lt;/strong&gt;: &lt;a href="https://github.com/allscreenshots/allscreenshots-cli" rel="noopener noreferrer"&gt;https://github.com/allscreenshots/allscreenshots-cli&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next?
&lt;/h2&gt;

&lt;p&gt;I'm actively developing this and would love feedback from the community:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What features would make this more useful for your workflow?&lt;/li&gt;
&lt;li&gt;Any edge cases or sites that don't render correctly?&lt;/li&gt;
&lt;li&gt;Integration ideas (GitHub Actions, VS Code, etc.)?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drop a comment below or open an issue on GitHub. Thanks for reading!&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>rust</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Debugging Chromium Crashes When Taking Full-Page Screenshots with Playwright</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Tue, 06 Jan 2026 13:49:35 +0000</pubDate>
      <link>https://forem.com/allscreenshots/debugging-chromium-crashes-when-taking-full-page-screenshots-with-playwright-43k1</link>
      <guid>https://forem.com/allscreenshots/debugging-chromium-crashes-when-taking-full-page-screenshots-with-playwright-43k1</guid>
      <description>&lt;p&gt;We recently ran into a frustrating issue with our screenshot API: Chromium was crashing with a segmentation fault when capturing full-page screenshots of large websites. Here's how we diagnosed and fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Our API uses Playwright with headless Chromium to capture screenshots. For most sites, everything worked fine. But for very tall pages, we'd get this crash:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[pid=422][err] Received signal 11 SEGV_MAPERR 000000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Followed by a stack trace and ultimately a &lt;code&gt;RENDER_FAILED&lt;/code&gt; error returned to the user. The &lt;code&gt;SEGV_MAPERR&lt;/code&gt; with address &lt;code&gt;000000000000&lt;/code&gt; is a null pointer dereference—Chromium was running out of memory during the screenshot compositing process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Setup
&lt;/h2&gt;

&lt;p&gt;We're running a Spring Boot + Kotlin application with Playwright inside Docker on a Linux VPS. The container was already configured with generous shared memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;allscreenshots-api &lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /dev/shm
Filesystem      Size  Used Avail Use% Mounted on
shm             2.0G     0  2.0G   0% /dev/shm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So that wasn't the issue. We also checked if the container had a memory limit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;allscreenshots-api &lt;span class="nb"&gt;cat&lt;/span&gt; /sys/fs/cgroup/memory.max
max
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No container limit either—it could use all available host memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Root Cause
&lt;/h2&gt;

&lt;p&gt;The real problem became obvious when we checked the host's actual memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt;
               total        used        free      shared  buff/cache   available
Mem:           1.9Gi       1.3Gi       178Mi        26Mi       628Mi       629Mi
Swap:             0B          0B          0B
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only ~630MB available and &lt;strong&gt;no swap space&lt;/strong&gt;. Our 2GB VPS was running Postgres, Caddy, and our application, leaving very little headroom.&lt;/p&gt;

&lt;p&gt;To understand why this matters, consider what happens when Chromium takes a full-page screenshot. For a page that's 1920×50,000 pixels at 32-bit color:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1920 × 50,000 × 4 bytes = ~384MB for the raw bitmap alone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add Chromium's compositing overhead, and you can easily spike to 1GB+ for a single large screenshot. With only 630MB available and no swap, the kernel's OOM killer terminates the process.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;We added 2GB of swap space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /swapfile
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile

&lt;span class="c"&gt;# Make it permanent across reboots&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After enabling swap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt;
               total        used        free      shared  buff/cache   available
Mem:           1.9Gi       1.3Gi       183Mi        26Mi       631Mi       638Mi
Swap:          2.0Gi          0B       2.0Gi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The large screenshot that was crashing now completes successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other Things We Considered
&lt;/h2&gt;

&lt;p&gt;Before identifying the root cause, we investigated several other potential fixes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Increasing Docker's shared memory&lt;/strong&gt; — Docker defaults to only 64MB for &lt;code&gt;/dev/shm&lt;/code&gt;, which is often too small for Chromium. You can increase it in your &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;your-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;shm_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1gb'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This wasn't our issue since we already had 2GB, but it's a common culprit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome flags for memory reduction&lt;/strong&gt; — Playwright already adds many memory-saving flags, but you can add more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BrowserType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LaunchOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"--disable-dev-shm-usage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"--disable-gpu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"--disable-software-rasterizer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"--single-process"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"--memory-pressure-off"&lt;/span&gt;
    &lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reducing screenshot quality&lt;/strong&gt; — Using JPEG instead of PNG with reduced quality uses less memory during encoding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScreenshotOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ScreenshotType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JPEG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;setQuality&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Capping maximum height&lt;/strong&gt; — If you can live with a height limit, this prevents the problem entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;maxHeight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;16384&lt;/span&gt;
&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;actualHeight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"document.documentElement.scrollHeight"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actualHeight&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Page height $actualHeight exceeds max, capping to $maxHeight"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// Return error or take partial screenshot&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a screenshot API where full-page capture is a core feature, this wasn't an option for us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Always configure swap on VPS instances running Chromium&lt;/strong&gt; — Even if you think you have enough RAM, screenshot operations can spike memory usage unpredictably. Swap provides a safety net.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check actual available memory, not just limits&lt;/strong&gt; — Container memory limits and shared memory size don't matter if the host itself is constrained.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;2GB is tight for a Playwright-based service&lt;/strong&gt; — If you're running a screenshot API alongside a database and web server, 4GB gives you much more breathing room.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The &lt;code&gt;SEGV_MAPERR 000000000000&lt;/code&gt; error is usually OOM&lt;/strong&gt; — When you see Chromium crash with a null pointer dereference during screenshot operations, think memory first.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Monitoring Going Forward
&lt;/h2&gt;

&lt;p&gt;We're now keeping an eye on swap usage to know when it's time to upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;free &lt;span class="nt"&gt;-h&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;Swap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If swap is constantly in use, that's a signal the VPS needs more RAM.&lt;/p&gt;




&lt;p&gt;Have you run into similar issues with Playwright or Puppeteer? We'd love to hear about your experience in the comments.&lt;/p&gt;

</description>
      <category>playwright</category>
      <category>kotlin</category>
      <category>memory</category>
      <category>programming</category>
    </item>
    <item>
      <title>Day 6: The Core Engine - Getting Playwright Running</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Tue, 06 Jan 2026 13:46:35 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-6-the-core-engine-getting-playwright-running-1e37</link>
      <guid>https://forem.com/allscreenshots/day-6-the-core-engine-getting-playwright-running-1e37</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 6 of 30. Today we capture our first screenshot. Finally, actual product work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We've spent the last five days on setup, planning, and infrastructure. While this is absolutely important work, it might feel a bit like time we could &lt;br&gt;
have spent this time better building the product, which, in our opinion, is a trap, since it helps to knows what you're building. &lt;/p&gt;

&lt;p&gt;But, at the same time, all of this planning doesn't deliver our product... So, let's get at it, and today we are writing the code that actually does the thing we're building: take a URL, render it in a browser and capture a screenshot! It won't be the full product, but it's a start.&lt;/p&gt;

&lt;p&gt;Sounds simple right? Perhaps, but when something sounds too simple, it often a case that we don't understand the problem we're solving well enough yet. &lt;br&gt;
Let's dive in to increase our understanding of the problem we're solving! &lt;/p&gt;
&lt;h2&gt;
  
  
  Why Playwright over Puppeteer?
&lt;/h2&gt;

&lt;p&gt;There are several screenshotting solutions, such as Playwright, Puppeteer, Selenium + Webdriver, Chrome Devtools Protocol (CDP), and tools like PhantomJS, SlimerJS, and CasperJS. Let's dive in.&lt;/p&gt;

&lt;p&gt;All of the aforementioned tools are headless browser automation tools. Some of them, like PhantomJS, SlimerJS, and CasperJS, are unfortunately no longer maintained, which makes the choice more limited, but perhaps a little easier. &lt;/p&gt;

&lt;p&gt;Based on the capabilities of the tools, we picked Playwright. We looked at the following criteria to make a well-informed decision:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Better auto-waiting.&lt;/strong&gt; Playwright automatically waits for elements to be ready before interacting. Puppeteer requires more manual wait logic. For screenshots, we especially need the "wait until the page looks done", and Playwright handles this well.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-browser from the start.&lt;/strong&gt; Playwright supports Chromium, Firefox, and WebKit out of the box while Puppeteer is Chrome-focused. While we're starting with Chromium only, having the option to support more browsers in the future is a nice feature. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cleaner device emulation.&lt;/strong&gt; Playwright has built-in device profiles for phones and tablets. &lt;code&gt;playwright.devices['iPhone 13']&lt;/code&gt; gives us the right viewport, user agent, and pixel density. This results in a lower amount of required config and less room for mistakes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Active development.&lt;/strong&gt; Microsoft backs Playwright, and it's evolving faster than Puppeteer. It's not that Puppeteer is abandoned, but Playwright feels like the future.&lt;/p&gt;
&lt;h2&gt;
  
  
  Setting up Playwright in Kotlin
&lt;/h2&gt;

&lt;p&gt;Here's where things get interesting. Playwright has official bindings for JavaScript, Python, Java, and C#. We're using Kotlin on the JVM, so the Java bindings work well.&lt;/p&gt;

&lt;p&gt;First, we need to add the dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.microsoft.playwright:playwright:1.50.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then install the browsers (this runs once during Docker build):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew playwright &lt;span class="nt"&gt;--install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Our first screenshot service
&lt;/h2&gt;

&lt;p&gt;Here's the core class that captures screenshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScreenshotService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;playwright&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;browser&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;LaunchOptions&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setHeadless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ScreenshotRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;contextOptions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NewContextOptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="c1"&gt;// Apply device emulation if requested&lt;/span&gt;
        &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s"&gt;"mobile"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;device&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s"&gt;"iPhone 13"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;contextOptions&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewportSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUserAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDeviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;"tablet"&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;device&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;devices&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="s"&gt;"iPad Pro 11"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="n"&gt;contextOptions&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewportSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;viewport&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setUserAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDeviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deviceScaleFactor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;contextOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setViewportSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;contextOptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;page&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NavigateOptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;30000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWaitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WaitUntilState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NETWORKIDLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ScreenshotOptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFullPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fullPage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ScreenshotType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PNG&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the naive version. It works, but we'll need to improve it. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first screenshot
&lt;/h2&gt;

&lt;p&gt;After getting everything wired up, we hit our local endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/api/v1/screenshots &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"url": "https://example.com"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fourteen seconds later: a screenshot. It worked! &lt;/p&gt;

&lt;p&gt;But 14 seconds is way too slow. Time to figure out why. (Didn't we say it sounds simple?)&lt;/p&gt;

&lt;h2&gt;
  
  
  What's making it slow?
&lt;/h2&gt;

&lt;p&gt;We added some timing logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser context creation: 245ms
Page creation: 12ms
Navigation: 11,847ms
Screenshot capture: 1,203ms
Context cleanup: 89ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's pretty clear: navigation is the killer. &lt;code&gt;WaitUntilState.NETWORKIDLE&lt;/code&gt; waits until there are no network requests for 500ms. On a site with analytics, chat widgets, and lazy-loaded images, that takes a very long time indeed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it faster
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Smarter wait strategies
&lt;/h3&gt;

&lt;p&gt;Instead of waiting for network idle, we can wait for specific conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Wait for DOM to be ready, not all resources&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NavigateOptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;30000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setWaitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;WaitUntilState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DOMCONTENTLOADED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// Then wait a bit for JavaScript rendering&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A simple change like this brought us from 14 seconds to 3-4 seconds on the same URL. That's about 3x faster!&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Block unnecessary resources
&lt;/h3&gt;

&lt;p&gt;To speed things even more, let's see if we can block some resources. We don't need analytics, ads, or tracking pixels for a proper rendering of screenshots, so eliminating those calls could speed things up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"**/*"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;resourceType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;resourceType&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resourceType&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"stylesheet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"font"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"media"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Allow these - they affect appearance&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"analytics"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
               &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"tracking"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt;
               &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ads"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait - we can't block images and stylesheets if we want accurate screenshots. Let's refine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"**/*"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;blockedPatterns&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;"google-analytics.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"googletagmanager.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"facebook.net"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"hotjar.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"intercom.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s"&gt;"segment.com"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;blockedPatterns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shaved off another 500ms on average. We need to extend the list of blocked patterns, make it configurable, have multi-language versions of it, and much more, but for a proof of concept, this will do for now. &lt;/p&gt;

&lt;h3&gt;
  
  
  3. Browser reuse
&lt;/h3&gt;

&lt;p&gt;There are more areas where we can optimise things. For example, creating a new browser for each request is wasteful. &lt;br&gt;
We're now reusing the browser instance and only creating new contexts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Service&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ScreenshotService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;playwright&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;browser&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;LaunchOptions&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeadless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setArgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="s"&gt;"--disable-gpu"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"--disable-dev-shm-usage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s"&gt;"--no-sandbox"&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;// Browser stays warm, contexts are per-request&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;capture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ScreenshotRequest&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;ByteArray&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* options */&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// ... capture logic&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;// Clean up context, keep browser&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser startup is ~500ms. Context creation is ~50ms. This is a big difference when handling multiple requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results after optimization
&lt;/h2&gt;

&lt;p&gt;With the same URL, the same machine, we got the following results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Before: 14 seconds&lt;/li&gt;
&lt;li&gt;After: 2.1 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a 6x improvement. The end result isn't blazing fast yet, but it's acceptable, and we'll work on improving this even further. &lt;br&gt;
The loading of complex sites still take 3-5 seconds, but simple sites are under 2 seconds. This is no issue at all for async calls, and could be acceptable in some situations for sync calls. &lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases we discovered
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Sites that block headless browsers.&lt;/strong&gt; Some sites detect and block automated browsers. They see no mouse movements, no scroll events, and they see headless Chrome signatures. We'll need to handle these situations gracefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Infinite scroll pages.&lt;/strong&gt; A "Full page" screenshot on an infinite scroll page is quite undefined behavior, which means we'll need to cap the maximum page height.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cookie consent popups.&lt;/strong&gt; A lot of sites show a GDPR popup which makes them end up in our screenshots, so this is yet another situation we need to handle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sites that require login.&lt;/strong&gt; Our API won't help here (yet).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Really slow sites.&lt;/strong&gt; Some sites take 30+ seconds to load. Our timeout handles this, but we need good error messages.&lt;/p&gt;

&lt;p&gt;Didn't we mention that we thought making a screenshot would be easy? We haven't even covered mobile devices, dark mode, animations, pagination, or any of the other edge cases we've discovered. Stay tuned for how we tackle these!&lt;/p&gt;

&lt;h2&gt;
  
  
  What we accomplished today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Got Playwright working in Kotlin/Spring Boot&lt;/li&gt;
&lt;li&gt;Captured our first programmatic screenshot&lt;/li&gt;
&lt;li&gt;Reduced capture time from 14s to ~2s&lt;/li&gt;
&lt;li&gt;Identified major edge cases to handle&lt;/li&gt;
&lt;li&gt;Learned a lot about how the web actually behaves&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the core of our product. Everything else is wrapping paper around this engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: week 1 retrospective
&lt;/h2&gt;

&lt;p&gt;Day 7 marks the end of week one. We'll step back and assess: what did we get done, what didn't work, and are we on track for a paying customer by day 15?&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.amazon.com/Web-Scraping-Python-Collecting-Modern/dp/1491985577?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;Web Scraping with Python&lt;/a&gt; by Ryan Mitchell&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Okay, we're not using Python, and this book is about scraping, not screenshots. But sometimes it's good to look beyond your own stack, and the principles between the two overlap significantly.&lt;/p&gt;

&lt;p&gt;Mitchell covers how websites work under the hood, how to handle JavaScript-rendered content, dealing with anti-bot measures, and the ethics of automated web access. Chapter 10 on JavaScript execution is particularly relevant - it explains why "waiting for the page to load" is so complicated in the modern web.&lt;/p&gt;

&lt;p&gt;Even if you're not doing web scraping, understanding how browsers render pages helps you build better web tools. This book is a practical, readable introduction to that world, and a must read in our book (ha!) if you want to dive in into the world of web automation.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 14 (previous 10 + 4 today)&lt;/li&gt;
&lt;li&gt;Lines of code: ~450&lt;/li&gt;
&lt;li&gt;Monthly hosting cost: $5.50&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;First screenshot captured: ✓&lt;/li&gt;
&lt;li&gt;Capture time: ~2-3 seconds&lt;/li&gt;
&lt;li&gt;Edge cases identified: 5&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>playwright</category>
      <category>puppeteer</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Day 5: Choosing Our Hosting - How We'll Run This for Under $20/month</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Mon, 05 Jan 2026 07:55:00 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-5-choosing-our-hosting-how-well-run-this-for-under-20month-2c00</link>
      <guid>https://forem.com/allscreenshots/day-5-choosing-our-hosting-how-well-run-this-for-under-20month-2c00</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 5 of 30. Today we're talking money - specifically, how to spend as little of it as possible.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hosting costs can have a significant impact on a bootstrapped project. We've seen projects spend $1000+/month on&lt;br&gt;
infrastructure for apps with zero users, especially when running on bigger cloud providers like AWS or Azure. We're&lt;br&gt;
optimizing for cheap until our needs justifies otherwise.&lt;/p&gt;

&lt;p&gt;Our target for &lt;a href="https://allscreenshots.com" rel="noopener noreferrer"&gt;allscreenshots&lt;/a&gt; is to spend less than $20 USD/month for our hosting costs and CI/CD infrastructure. In this post we're diving into how we intend to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The options we considered
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Vercel / Netlify / Railway
&lt;/h3&gt;

&lt;p&gt;Options like these are the current developer choice, especially for NextJS platforms. The platforms provide a great Developer Experience (DX), generous free tiers and overall, painless deployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why we didn't choose them:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While this might be viable options for some, we had a free reasons not to go for them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free tiers have limits which exceed our needs, which could get expensive fast&lt;/li&gt;
&lt;li&gt;Serverless doesn't play well with headless browsers (cold starts kill us)&lt;/li&gt;
&lt;li&gt;We'd still need separate compute for Playwright workers&lt;/li&gt;
&lt;li&gt;Costs become unpredictable at scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a static site or simple API, these are great. For a screenshot service running headless Chrome, they're not the&lt;br&gt;
right fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS / GCP / Azure
&lt;/h3&gt;

&lt;p&gt;The enterprise options. Almost infinitely scalable, but at cost of money and complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why we didn't choose them:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Overkill for our scale&lt;/li&gt;
&lt;li&gt;Pricing is confusing and easy to mess up&lt;/li&gt;
&lt;li&gt;Free tiers expire or have gotchas&lt;/li&gt;
&lt;li&gt;We'd spend more time on infrastructure which we rather spend on improving the product&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;While in the future we might need a bigger scale, we don't see the need today. However, since we're running on Docker&lt;br&gt;
images, migrating to a cloud provider could be relatively straightforward in the future.&lt;/p&gt;

&lt;h3&gt;
  
  
  DigitalOcean / Linode / Vultr
&lt;/h3&gt;

&lt;p&gt;These VPS providers provide a solid middle ground. They have simple and predictable pricing, good docs, and offer good&lt;br&gt;
capacity at reasonable costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Benefits:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;$5-10/month droplets are capable enough&lt;/li&gt;
&lt;li&gt;Managed databases available&lt;/li&gt;
&lt;li&gt;Good reputation in the indie hacker community&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Vultr is our favorite and goto hosting provider, and we've used Vultr successfully in other projects. &lt;br&gt;
However, we found something cheaper which we are willing to give a try.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hetzner
&lt;/h3&gt;

&lt;p&gt;A German hosting company that's popular in Europe but less known in the US.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why we chose them:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CX22 VPS: 2 vCPUs, 4GB RAM, 40GB SSD - $4.50/month&lt;/li&gt;
&lt;li&gt;Same specs on DigitalOcean: $24/month&lt;/li&gt;
&lt;li&gt;Data centers in EU (good for GDPR) and US&lt;/li&gt;
&lt;li&gt;Reliable, been around since 1997&lt;/li&gt;
&lt;li&gt;No surprise pricing - what you see is what you pay&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The value is above seems like a very good deal, and we'll do some proper benchmarks just to make sure Hetzner is up to the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our infrastructure breakdown
&lt;/h2&gt;

&lt;p&gt;Here's exactly what we're running and what it costs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VPS (2 vCPU, 4GB RAM)&lt;/td&gt;
&lt;td&gt;Hetzner CX22&lt;/td&gt;
&lt;td&gt;$4.50/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Object Storage (screenshots)&lt;/td&gt;
&lt;td&gt;Cloudflare R2&lt;/td&gt;
&lt;td&gt;$0 (free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;NameCheap&lt;/td&gt;
&lt;td&gt;~$10/year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email (transactional)&lt;/td&gt;
&lt;td&gt;Resend&lt;/td&gt;
&lt;td&gt;$0 (free tier)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL&lt;/td&gt;
&lt;td&gt;Let's Encrypt&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&lt;/td&gt;
&lt;td&gt;GitHub Actions&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Container Registry&lt;/td&gt;
&lt;td&gt;GitHub Packages&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Total: ~$5.50/month&lt;/strong&gt; (plus ~$0.80/month domain cost)&lt;/p&gt;

&lt;p&gt;Well under our $20 budget, leaving room for growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Cloudflare R2 for storage?
&lt;/h2&gt;

&lt;p&gt;Screenshots need to live somewhere, and an S3 compatible storage is the standard. We have several options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS S3&lt;/strong&gt;: This is the standard, and quite affordable for storage, but egress fees add up when serving images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backblaze B2&lt;/strong&gt;: Cheap storage, free egress through Cloudflare, but no experience with this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare R2&lt;/strong&gt;: S3-compatible, zero egress fees, generous free tier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;R2 gives us 10GB storage and 10 million reads/month free. That's a lot of screenshots before we pay anything. And when&lt;br&gt;
we do pay, it's $0.015/GB/month for storage with no egress fees.&lt;/p&gt;

&lt;p&gt;For a service that's literally serving images, zero egress fees is huge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden costs we're avoiding
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Managed databases&lt;/strong&gt; - We're running Postgres in Docker on the same VPS. Yes, we're responsible for backups (automated&lt;br&gt;
with a cron job to R2, plus the machine itself it backed up). But managed Postgres starts at $15+/month, with unpredictable costs at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Managed Redis&lt;/strong&gt; - We're not using Redis yet. Postgres handles our job queue, and caching will be done on the application layer. This is one less service to pay for and manage, which keeps things simple.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Log aggregation&lt;/strong&gt; - We're using Docker's built-in logging for now. More fancy observability, like Datadog, New Relic or ELK can wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring&lt;/strong&gt; - A free uptime checker (we're using UptimeRobot) and basic health endpoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CDN&lt;/strong&gt; - Cloudflare's free tier in front of everything. Free SSL, free caching, free DDoS protection.&lt;/p&gt;

&lt;h2&gt;
  
  
  When we'll upgrade
&lt;/h2&gt;

&lt;p&gt;We're not being cheap for the sake of it, plus, we see this more as being frugal with our budget. &lt;br&gt;
We're trying to make a conscious choice about how we spend our budget, and we allow for growth.&lt;/p&gt;

&lt;p&gt;These are example scenarios, and will most likely change over time, but a possible future plan could look like the following:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 100 paying users:&lt;/strong&gt; Move Postgres to a managed service, and backups and maintenance become worth paying for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At 100,000 daily screenshots:&lt;/strong&gt; Consider a second worker VPS or upgrade to a beefier single machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;At $500/month revenue:&lt;/strong&gt; Revisit all infrastructure decisions. We'll have data on what actually needs scaling.&lt;/p&gt;

&lt;p&gt;Until then, we're using a slightly scrappy approach. If you have comments or suggestions, we'd love to hear from you!&lt;/p&gt;

&lt;h2&gt;
  
  
  The risks of cheap hosting
&lt;/h2&gt;

&lt;p&gt;Let's be honest about the trade-offs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single point of failure.&lt;/strong&gt; One VPS means one machine to fail. We're accepting this risk. Hetzner has good uptime, and&lt;br&gt;
we can restore from backups to a new VPS in under an hour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Limited resources.&lt;/strong&gt; 4GB RAM is enough for Postgres + Spring Boot + a couple Playwright instances. But we can't run&lt;br&gt;
ten parallel browser sessions. We'll optimize carefully and queue jobs, and most likely we'll spin up more machines when we require more capacity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No geographic redundancy.&lt;/strong&gt; All our infra is in one Hetzner data center. If that data center has issues, we're down.&lt;br&gt;
Again, acceptable at this stage, but it's something we'll look into when we need to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual scaling.&lt;/strong&gt; Adding capacity means provisioning new VPS instances manually since there is no auto-scaling. This is fine fine for now to keep&lt;br&gt;
our costs under control, but might change in the near future.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Finalized hosting decisions&lt;/li&gt;
&lt;li&gt;Set up Cloudflare R2 bucket for screenshot storage&lt;/li&gt;
&lt;li&gt;Configured Cloudflare DNS and SSL&lt;/li&gt;
&lt;li&gt;Created backup script for Postgres → R2&lt;/li&gt;
&lt;li&gt;Set up UptimeRobot monitoring&lt;/li&gt;
&lt;li&gt;Updated docker-compose with R2 credentials&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total spent so far: $4.50 for the Hetzner VPS (first month). Everything else is on a free tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: the core engine
&lt;/h2&gt;

&lt;p&gt;On Day 6 we're finally writing the fun code. Getting Playwright running, capturing our first programmatic screenshot, and&lt;br&gt;
learning what breaks when you try to render the web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.thefrugalarchitect.com/" rel="noopener noreferrer"&gt;The Frugal Architect&lt;/a&gt; by Werner Vogels&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Werner Vogels is Amazon's CTO, and this collection of essays explores cost-conscious system design. The irony of AWS's&lt;br&gt;
CTO writing about frugality isn't lost on us, but the principles are solid.&lt;/p&gt;

&lt;p&gt;His core laws include "make cost a non-functional requirement," "unobserved systems lead to unknown costs," and &lt;br&gt;
"cost-aware architectures implement cost controls."&lt;/p&gt;

&lt;p&gt;The key insight for us: cost efficiency isn't something you bolt on later. It's a design constraint from day one.&lt;br&gt;
Choosing Hetzner over AWS, R2 over S3, and Postgres-as-queue over Redis aren't just about saving money - they're about&lt;br&gt;
building a sustainable business where unit economics work even at small scale.&lt;/p&gt;

&lt;p&gt;The essays are free to read online, but we'd recommend bookmarking them for reference.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 10 (previous 8 + 2 today)&lt;/li&gt;
&lt;li&gt;Lines of code: ~250&lt;/li&gt;
&lt;li&gt;Monthly hosting cost: $5.50&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;Storage configured: ✓&lt;/li&gt;
&lt;li&gt;Monitoring active: ✓&lt;/li&gt;
&lt;li&gt;Backups automated: ✓&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the service in action, checkout &lt;a href="https://allscreenshots.com" rel="noopener noreferrer"&gt;allscreenshots&lt;/a&gt; for a free trial.&lt;/p&gt;

</description>
      <category>infrastructureascode</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 4: Setting Up CI/CD</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Sun, 04 Jan 2026 11:37:39 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-4-setting-up-cicd-19jk</link>
      <guid>https://forem.com/allscreenshots/day-4-setting-up-cicd-19jk</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 4 of 30. Today we're automating deployment before we have much to deploy.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One of the most important lessons we've learned is that CI/CD is not optional. We want to deploy quickly, reliably and as often as possible.&lt;br&gt;
Setting this up for allscreenshots.com before we have anything to deploy might seem backwards since we don't have a working product yet. Why spend time on deployment pipelines?&lt;/p&gt;

&lt;p&gt;One of the reasons for this is because we've learned the hard way: the longer you wait with setting an automated deployment pipeline, the harder it gets.&lt;br&gt;
You'll end up with more dependencies, more complexity (for example, the provisioning of a database, failover, auth, etc), more edge cases. Deploying manually, similar to testing manually, is not an option for us, so we're setting this up early.&lt;/p&gt;

&lt;p&gt;By the end of today, every push to our &lt;code&gt;main&lt;/code&gt; branch will automatically build, test, and deploy our app to a real server.&lt;/p&gt;
&lt;h2&gt;
  
  
  The goal: boring, reliable deploys
&lt;/h2&gt;

&lt;p&gt;We want deployments to be a non-event. The idea is to push code, wait a few moments, and it's live. There are no manual steps and no manual verifications.&lt;/p&gt;

&lt;p&gt;Here's what we're setting up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;GitHub Actions&lt;/strong&gt; runs on every push&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt; the React frontend and bundle it into the Spring Boot app&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run tests&lt;/strong&gt; (even though we barely have any yet)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a single Docker image&lt;/strong&gt; for our combined service&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push image&lt;/strong&gt; to GitHub Container Registry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy&lt;/strong&gt; to our VPS via SSH and some scripts&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  One deployable unit
&lt;/h2&gt;

&lt;p&gt;We're keeping things simple: one Docker image that contains everything. The React frontend gets built and bundled into the Spring Boot JAR as static resources. Spring Boot serves them directly - no separate web server, no nginx, no complexity.&lt;/p&gt;

&lt;p&gt;Why this approach?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simpler deployment.&lt;/strong&gt; One container to build, push, and run. No orchestrating multiple services.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simpler networking.&lt;/strong&gt; No CORS issues, no reverse proxy configuration. The API and frontend share the same origin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Simpler development.&lt;/strong&gt; Run one thing locally, get everything. No "frontend is on port 3000, backend is on port 8080" confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good enough for our scale.&lt;/strong&gt; If we ever need to scale frontend and backend separately, we can split later. Right now, that's premature optimization.&lt;/p&gt;
&lt;h2&gt;
  
  
  The GitHub Actions workflow
&lt;/h2&gt;

&lt;p&gt;Here's our &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;REGISTRY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
  &lt;span class="na"&gt;IMAGE_NAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up JDK &lt;/span&gt;&lt;span class="m"&gt;21&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-java@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;java-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;21'&lt;/span&gt;
          &lt;span class="na"&gt;distribution&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;temurin'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Node&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;20'&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Install frontend dependencies&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run frontend tests&lt;/span&gt;
        &lt;span class="na"&gt;working-directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./frontend&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test -- --passWithNoTests&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run API tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./gradlew test&lt;/span&gt;

  &lt;span class="na"&gt;build-and-push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Log in to Container Registry&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v3&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push image&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v5&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest&lt;/span&gt;

  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-and-push&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main'&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to VPS&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;cd /opt/screenshot-api&lt;/span&gt;
            &lt;span class="s"&gt;docker compose pull&lt;/span&gt;
            &lt;span class="s"&gt;docker compose up -d&lt;/span&gt;
            &lt;span class="s"&gt;docker system prune -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing fancy. It runs tests, builds one Docker image with everything bundled, pushes it to GitHub's free container registry, then SSHs into our server to pull and restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dockerfile
&lt;/h2&gt;

&lt;p&gt;One Dockerfile that builds everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Stage 1: Build the frontend&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:20-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;frontend-build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /frontend&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; frontend/package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; frontend/ ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm run build

&lt;span class="c"&gt;# Stage 2: Build the backend (with frontend bundled in)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;eclipse-temurin:21-jdk-alpine&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;backend-build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Copy Gradle files first for better caching&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; gradlew build.gradle.kts settings.gradle.kts ./&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; gradle gradle&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;./gradlew dependencies &lt;span class="nt"&gt;--no-daemon&lt;/span&gt;

&lt;span class="c"&gt;# Copy frontend build output to Spring Boot static resources&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=frontend-build /frontend/dist src/main/resources/static&lt;/span&gt;

&lt;span class="c"&gt;# Copy source and build&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src src&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;./gradlew bootJar &lt;span class="nt"&gt;--no-daemon&lt;/span&gt;

&lt;span class="c"&gt;# Stage 3: Runtime image&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; eclipse-temurin:21-jre-alpine&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Playwright dependencies will be added later&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apk add &lt;span class="nt"&gt;--no-cache&lt;/span&gt; chromium

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=backend-build /app/build/libs/*.jar app.jar&lt;/span&gt;

&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["java", "-jar", "app.jar"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Frontend build&lt;/strong&gt; - npm install, npm build, outputs to &lt;code&gt;dist/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend build&lt;/strong&gt; - Copies frontend output into &lt;code&gt;src/main/resources/static&lt;/code&gt;, then builds the Spring Boot JAR&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime&lt;/strong&gt; - Minimal JRE image with just the JAR&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The final image is around 250MB. If needed, we can optimise this later using multi-stage builds and &lt;code&gt;jlink&lt;/code&gt; and &lt;code&gt;jdeps&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spring Boot serving static files
&lt;/h2&gt;

&lt;p&gt;Spring Boot automatically serves anything in &lt;code&gt;src/main/resources/static&lt;/code&gt; at the root path, no configuration needed is needed for this.&lt;/p&gt;

&lt;p&gt;The only thing to make this work is that we need to handle the client-side routing. When someone navigates to &lt;code&gt;/dashboard&lt;/code&gt; directly, Spring Boot needs to serve &lt;code&gt;index.html&lt;/code&gt; instead of returning a 404 response:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Controller&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SpaController&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/{path:[^\\.]*}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"forward:/index.html"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forwards any path without a dot (i.e., not a file request) to &lt;code&gt;index.html&lt;/code&gt;, letting React Router handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Compose on the server
&lt;/h2&gt;

&lt;p&gt;On our VPS, we have a simple &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3.8'&lt;/span&gt;

&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/yourusername/screenshot-api:latest&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:8080"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/screenshots&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;STORAGE_ENDPOINT=${STORAGE_ENDPOINT}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;STORAGE_KEY=${STORAGE_KEY}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;STORAGE_SECRET=${STORAGE_SECRET}&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:18-alpine&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;postgres_data:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_DB=screenshots&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;POSTGRES_PASSWORD=${DB_PASSWORD}&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;postgres_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We have two containers: our application and our Postgres database. The environment variables live in a &lt;code&gt;.env&lt;/code&gt; file on the server that we created manually once. Secrets stay out of git.&lt;/p&gt;

&lt;h2&gt;
  
  
  The VPS setup (one-time)
&lt;/h2&gt;

&lt;p&gt;We're using a Hetzner CX22 - 2 vCPUs, 4GB RAM, 40GB disk, at around $4.50/month, which is more than enough for now.&lt;/p&gt;

&lt;p&gt;Our initial server setup took about 30 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Update system&lt;/span&gt;
apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# Install Docker&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://get.docker.com | sh

&lt;span class="c"&gt;# Create app directory&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/screenshot-api
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/screenshot-api

&lt;span class="c"&gt;# Create .env file with secrets&lt;/span&gt;
nano .env

&lt;span class="c"&gt;# Create docker-compose.yml&lt;/span&gt;
nano docker-compose.yml

&lt;span class="c"&gt;# Login to GitHub Container Registry&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$GITHUB_TOKEN&lt;/span&gt; | docker login ghcr.io &lt;span class="nt"&gt;-u&lt;/span&gt; USERNAME &lt;span class="nt"&gt;--password-stdin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server just needs Docker and our compose file, everything else comes from the container image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this approach?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions is free&lt;/strong&gt; for public repos and has generous limits for private ones. This saves us from running something like Jenkins or paying for CircleCI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Container Registry is free&lt;/strong&gt; for public images and cheap for private, this allows us to skip an extra dependency on something like Docker Hub.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Compose is simple.&lt;/strong&gt; We could use Kubernetes, but for a single VPS with two containers, that's a massive overkill. Compose does exactly what we need for now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single image is simpler.&lt;/strong&gt; One build, one push, one pull, one restart. There is no coordinating of multiple containers, no version mismatches between frontend and backend, and we're staying away from microservices or distributed services as long as we can to reduce complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH deployment is good enough.&lt;/strong&gt; Fancy zero-downtime deployments can come later. For now, a few seconds of downtime during deploys is fine, and it's easy to improve this in the near future&lt;/p&gt;

&lt;h2&gt;
  
  
  What we didn't do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No Kubernetes.&lt;/strong&gt; Kubernetes is a quite complex thing to manage, and we have no need for it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No Terraform.&lt;/strong&gt; Our infrastructure is one VPS. We can recreate it manually under 30 minutes if needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No separate staging environment.&lt;/strong&gt; We'll test locally and in CI. Our staging environment, which is definitely a good idea, can come later when the need is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No blue-green deployments.&lt;/strong&gt; This is overkill for now. Docker Compose restarts are fast enough, we can restart in about 10 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No separate frontend/backend containers.&lt;/strong&gt; One deployable unit keeps things simple. We can split this later if needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the pipeline
&lt;/h2&gt;

&lt;p&gt;We pushed a dummy commit to verify everything works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;GitHub Actions triggered ✓&lt;/li&gt;
&lt;li&gt;Tests passed ✓&lt;/li&gt;
&lt;li&gt;Docker image built (frontend + backend combined) ✓&lt;/li&gt;
&lt;li&gt;Image pushed to registry ✓&lt;/li&gt;
&lt;li&gt;SSH deployment succeeded ✓&lt;/li&gt;
&lt;li&gt;App running on VPS ✓&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It took about 4 minutes end-to-end. This is fast enough for our needs, and we can optimize this later if it bothers us enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we accomplished today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wrote GitHub Actions workflow for CI/CD&lt;/li&gt;
&lt;li&gt;Created single Dockerfile that bundles frontend into backend&lt;/li&gt;
&lt;li&gt;Set up Docker Compose on VPS&lt;/li&gt;
&lt;li&gt;Configured secrets in GitHub&lt;/li&gt;
&lt;li&gt;Verified the full pipeline works&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From now on, shipping is just &lt;code&gt;git push&lt;/code&gt;. That's a good feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: choosing our hosting
&lt;/h2&gt;

&lt;p&gt;Day 5 we'll dig deeper into the hosting decision. Why Hetzner? What are the alternatives? How do we think about cost vs. convenience at this stage?&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.amazon.com/Phoenix-Project-DevOps-Helping-Business/dp/1942788290?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;The Phoenix Project&lt;/a&gt; by Gene Kim, Kevin Behr &amp;amp; George Spafford&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This novel (yes, a novel about IT operations) transformed how we think about deployment and DevOps. It follows an IT manager at a struggling company and shows how applying manufacturing principles to software delivery can fix broken organizations.&lt;/p&gt;

&lt;p&gt;The core insight we got was: work in progress is the enemy. Long-lived branches, manual deployments, and "we'll automate it later" attitudes create bottlenecks that slow everything down.&lt;/p&gt;

&lt;p&gt;Setting up CI/CD on day 4 of a project might seem premature, but our experience and the lessons from this book convinced us that the pain of manual deployment compounds over time, while the investment in automation pays dividends immediately.&lt;/p&gt;

&lt;p&gt;We can really recommend this book. It's a quick, engaging read, and while it is written for the DevOps world, it reads like a novel.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 8 (1 + 2 + 2 + 3 today)&lt;/li&gt;
&lt;li&gt;Lines of code: ~200 (yml files count, right?)&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;CI/CD pipeline: ✓&lt;/li&gt;
&lt;li&gt;Auto-deployment: ✓&lt;/li&gt;
&lt;li&gt;VPS running: ✓&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kotlin</category>
      <category>cicd</category>
      <category>github</category>
    </item>
    <item>
      <title>Day 3: Architecture Sketch on a Napkin</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Sat, 03 Jan 2026 16:39:18 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-3-architecture-sketch-on-a-napkin-50f</link>
      <guid>https://forem.com/allscreenshots/day-3-architecture-sketch-on-a-napkin-50f</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 3 of 30. Today we're drawing boxes and arrows before writing our code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As a software developer, it's very tempting to just start coding. But while tempting, we've been burned before, &lt;br&gt;
by diving in without a clear picture of how the pieces connect. A few hours of sketching and brainstorming now &lt;br&gt;
could potentially save us days (or more!) of rework later, so that's exactly what we'll do in this session.&lt;/p&gt;

&lt;p&gt;For those who are just jumping in: we're building &lt;a href="https://allscreenshots.com" rel="noopener noreferrer"&gt;allscreenshots&lt;/a&gt;, our SaaS for screenshot automation.&lt;/p&gt;

&lt;p&gt;Just a small note: this isn't a "formal" architecture document. It's just the napkin sketch, the document we'll use &lt;br&gt;
for discussions and some our decision-making, but with the full understanding that our end solution will most likely&lt;br&gt;
deviate from this in the future, but it's the mental model we'll hold in our heads while building our target state.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two API modes: sync and async
&lt;/h2&gt;

&lt;p&gt;Before we've even started, we've already introduced some version of feature creep: we're offering two ways to capture screenshots.&lt;br&gt;
We thought long and hard about this, like can we get away with just 1 version for simplicity reasons, but given our &lt;br&gt;
domain, and our core focus on making screenshots, we've decided that we need to offer both (but our implementation phase&lt;br&gt;
will focus on 1 to start with).&lt;/p&gt;

&lt;p&gt;So, after thinking through the use cases, we're offering two ways to capture screenshots:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synchronous (v1 focus):&lt;/strong&gt; A request comes in, we capture the screenshot, and we return it directly. &lt;br&gt;
This is simple (from the consumer perspective), immediate and great for most use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asynchronous (v2):&lt;/strong&gt; A request comes in, we queue a job, and return a job id immediately. &lt;br&gt;
The client polls or receives a webhook call when done. This solution would be better for high-volume batch processing and for cases&lt;br&gt;
where direct feedback isn't an immediate requirement.&lt;/p&gt;

&lt;p&gt;Our goal is to build the sync API first. The reason for this is that it's simpler, it covers 80% of use our currently identified use cases, &lt;br&gt;
and gets us to market faster, which allows us to get immediate feedback. The async API will come later, but will reuse a lot of the building&lt;br&gt;
blocks we introduced while building the sync API.&lt;/p&gt;
&lt;h2&gt;
  
  
  The sync happy path
&lt;/h2&gt;

&lt;p&gt;Here's what happens when someone requests a screenshot synchronously:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftfgepltmldhbjxjmpubq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftfgepltmldhbjxjmpubq.png" alt=" " width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Simple flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client sends URL&lt;/li&gt;
&lt;li&gt;We validate (API key, quota, URL format)&lt;/li&gt;
&lt;li&gt;We capture the screenshot&lt;/li&gt;
&lt;li&gt;We upload to storage&lt;/li&gt;
&lt;li&gt;We return the image URL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total time: 2-5 seconds depending on the target site.&lt;/p&gt;
&lt;h2&gt;
  
  
  The async happy path (future)
&lt;/h2&gt;

&lt;p&gt;For batch processing or very slow sites, async makes more sense:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa0wk0dbni4uivnemreju.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa0wk0dbni4uivnemreju.png" alt=" " width="800" height="596"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The async flow decouples accepting work from doing work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client sends request with &lt;code&gt;async: true&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;We create a job and return immediately (50ms)&lt;/li&gt;
&lt;li&gt;Background worker processes the job&lt;/li&gt;
&lt;li&gt;Client polls for result (or receives webhook)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We're not building this yet, but the architecture supports it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why sync first?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Simpler implementation.&lt;/strong&gt; No job queue, no polling logic, no webhook infrastructure. Less code, fewer bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faster time to market.&lt;/strong&gt; We can ship a working product sooner.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Good enough for most cases.&lt;/strong&gt; If your screenshot takes 3 seconds, waiting 3 seconds is fine. You don't need async complexity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Easier to explain.&lt;/strong&gt; "Send URL, get screenshot" is simpler than "send URL, get job ID, poll for result."&lt;/p&gt;

&lt;p&gt;We'll add async when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Customers ask for batch processing&lt;/li&gt;
&lt;li&gt;We need to handle very slow sites (30+ seconds)&lt;/li&gt;
&lt;li&gt;We want to offer webhooks&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  The components
&lt;/h2&gt;

&lt;p&gt;Here's what we're actually building:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌────────────────────────────────────────────────────────┐
│                        VPS                             │
│                                                        │
│   ┌────────────────────────────────────────────────┐   │
│   │              Spring Boot API                   │   │
│   │                                                │   │
│   │  ┌──────────┐  ┌──────────┐  ┌──────────────┐  │   │
│   │  │  REST    │  │  Auth &amp;amp;  │  │  Screenshot  │  │   │
│   │  │ Endpoints│─▶│  Quota   │─▶│   Service    │  │   │
│   │  └──────────┘  └──────────┘  └──────────────┘  │   │
│   │                                     │          │   │
│   │                              ┌──────▼───────┐  │   │
│   │                              │   Browser    │  │   │
│   │                              │    Pool      │  │   │
│   │                              └──────────────┘  │   │
│   └────────────────────────────────────────────────┘   │
│          │                                             │
│   ┌──────▼───────┐                                     │
│   │   Postgres   │                                     │
│   │  (users,     │                                     │
│   │   keys,      │                                     │
│   │   usage)     │                                     │
│   └──────────────┘                                     │
│                                                        │
└────────────────────────────────────────────────────────┘
           │
           ▼
    ┌─────────────┐     ┌─────────────┐
    │   React     │     │  Cloudflare │
    │   Frontend  │     │     R2      │
    │             │     │  (images)   │
    └─────────────┘     └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Spring Boot API&lt;/strong&gt; - The main application. For sync mode, everything happens in the request thread or in coroutines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser Pool&lt;/strong&gt; - Reusable browser instances. Creating a browser is slow (~500ms), so we keep a few warm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres&lt;/strong&gt; - Users, API keys, usage tracking. Most likely not used for job queuing in sync mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;R2 Storage&lt;/strong&gt; - Screenshots uploaded here. Depending on the configuration, we return signed URLs that expire after a period of time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;React Frontend&lt;/strong&gt; - Landing page and dashboard. Static files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync API design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create Screenshot (sync)
&lt;/h3&gt;

&lt;p&gt;The current design is just a proposal. There's a high chance we'll make this a GET API instead of post, so it's easier&lt;br&gt;
to call the API from an image tag, but in terms of functionality, the offered features will be similar.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v1/screenshots
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"device"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"desktop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"full_page"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"png"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response (200 OK):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scr_abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://storage.../scr_abc123.png?signature=..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2024-01-15T10:30:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1080&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"file_size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;245678&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"capture_time_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2340&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&amp;lt;binary data&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response includes the image URL immediately, or will include some meta-data. No polling is required to capture the screenshot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeout handling
&lt;/h3&gt;

&lt;p&gt;Sync requests have a configurable timeout. If the page doesn't load by then we'll return an error to stop the client from waiting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Page did not load within 30 seconds"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For sites that regularly take longer, or multiple pages need to be captured, async mode will be the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async API design (future)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Create Screenshot (async)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/v1/screenshots
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"async"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"webhook_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://yoursite.com/webhook"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response (202 Accepted):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scr_abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-15T10:30:00Z"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Poll for status
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/v1/screenshots/scr_abc123def456
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response when pending:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scr_abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response when complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scr_abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://storage.../scr_abc123.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Webhook callback
&lt;/h3&gt;

&lt;p&gt;If &lt;code&gt;webhook_url&lt;/code&gt; provided, we POST to it when done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"screenshot.completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scr_abc123def456"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"completed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"image_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://..."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What we're deliberately not building yet
&lt;/h2&gt;

&lt;p&gt;There are a lot of things we're not building yet as part of this iteration, and the list is too long to include here,&lt;br&gt;
but some notable items which are not built yet, but which will be built later, are things like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple browser types&lt;/strong&gt; - Chromium only. We'll implement Firefox/WebKit and other browsers later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshot caching&lt;/strong&gt; - We want to offer some form of configurable caching, but at this moment, we'll always make a screenshot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizontal scaling&lt;/strong&gt; - At this moment, we'll run a simple VPS. We can scale both horizontally and vertically when we need to, and will &lt;br&gt;
do so depending on our needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did today
&lt;/h2&gt;

&lt;p&gt;Mostly thinking and sketching:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drew the architecture diagrams&lt;/li&gt;
&lt;li&gt;Designed both sync and async APIs&lt;/li&gt;
&lt;li&gt;Decided to build sync first&lt;/li&gt;
&lt;li&gt;Identified what we're &lt;em&gt;not&lt;/em&gt; building, yet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We haven't pushed any code today, on purpose. Our focus is on getting the foundation right, and having clarity on where we're going, which is&lt;br&gt;
worth our time as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: CI/CD on day one
&lt;/h2&gt;

&lt;p&gt;Tomorrow, we're getting our hands dirty, and we'll finally(?) be writing some code, plus we're setting up the deployment pipeline, &lt;br&gt;
where every successful build will end up straight in production, which will allow us to iterate fast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.amazon.com/Designing-Data-Intensive-Applications-Reliable-Maintainable/dp/1449373321?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;Designing Data-Intensive Applications&lt;/a&gt; by Martin Kleppmann&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the book we wish we'd read earlier in our careers. It fundamentally changed how we think about systems.&lt;/p&gt;

&lt;p&gt;Kleppmann covers databases, distributed systems, batch processing, stream processing - all the building blocks of modern applications. But more importantly, he explains &lt;em&gt;why&lt;/em&gt; things work the way they do.&lt;/p&gt;

&lt;p&gt;The chapter on message queues vs. request/response patterns directly informed our sync-first decision. Sync is simpler and sufficient for our use case, &lt;br&gt;
and async adds complexity that we don't need just yet.&lt;/p&gt;

&lt;p&gt;Designing Data-Intensive Applications is not a quick read. But if you're building anything that handles large amounts of data, then this &lt;br&gt;
book is an essential read.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 5 (1 + 2 + 2 today)&lt;/li&gt;
&lt;li&gt;Lines of code: ~50 (still mostly boilerplate)&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;Architecture documented: ✓&lt;/li&gt;
&lt;li&gt;API design (sync): ✓&lt;/li&gt;
&lt;li&gt;API design (async): ✓ (documented for later)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>architecture</category>
      <category>programming</category>
    </item>
    <item>
      <title>Day 2: Tech Stack Decision - Why Kotlin/Spring Boot + React + Postgres</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Sat, 03 Jan 2026 16:30:54 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-2-tech-stack-decision-why-kotlinspring-boot-react-postgres-f6</link>
      <guid>https://forem.com/allscreenshots/day-2-tech-stack-decision-why-kotlinspring-boot-react-postgres-f6</guid>
      <description>&lt;p&gt;&lt;strong&gt;Day 2 of 30. Zero lines of code yesterday, but today we're laying the foundation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Picking a tech stack for &lt;a href="https://allscreenshots.com" rel="noopener noreferrer"&gt;allscreenshots&lt;/a&gt; is an important decision. It's easy to spend days researching, comparing benchmarks, reading opinions and all of that.&lt;br&gt;
However, we're not doing that for this project. We're going to pick something we know well and that we can move fast in. Here's what we chose and why,&lt;br&gt;
something we decided in about an hour, but with the idea that new insights may lead to new solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backend: Kotlin with Spring Boot
&lt;/h2&gt;

&lt;p&gt;Kotlin on the backend. With a team who is mostly specialised in Java, Kotlin is our next step up. Combined with Spring Boot, and in the future perhaps other frameworks, it's a solid foundation for our app.&lt;/p&gt;

&lt;p&gt;Our main reasons for picking Kotlin on the backend:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We're fast in it.&lt;/strong&gt; We have years of great experience with Spring Boot which matters more than anything else. The "best" framework is the one where you can ship features without constantly checking documentation. Also, LLM models are really good for the boring parts of the code, like generating the boilerplate, which makes us move just a bit faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's boring in a good way.&lt;/strong&gt; Spring Boot has solved most problems we'll encounter. Authentication, scheduling, database access, API validation, testing: all of these functionalities are included. There are well-documented way to do all of it, and most problems have been solved before. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The JVM is actually great for this workload.&lt;/strong&gt; We'll be running headless browsers, managing job queues and handling concurrent API requests. &lt;br&gt;
The JVM handles concurrency extremely well, and Kotlin's coroutines give us async capabilities when we need them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment is simple now.&lt;/strong&gt; The old complaint about Spring Boot was heavy containers and slow startups. With modern containerization and decent VPS hosting, our app will boot in a few seconds, and will be able to handle a decent load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We've done this before.&lt;/strong&gt; We've built a few apps with Spring Boot and Kotlin, and we know what we're doing. We can leverage some of the best practices we've learned over the years, and use some of the software solutions, and CI/CD approach from our previous projects.&lt;/p&gt;

&lt;p&gt;The trade-off? There might be a higher memory footprint than applications written in Go for example. &lt;br&gt;
We'll need a solution with at least 1GB RAM, but more is better, and we'll dive a bit deeper in our decision-making process regarding hosting&lt;br&gt;
("Why We Didn't Pick a Cloud Provider", or something like that) in a future post. &lt;/p&gt;

&lt;h2&gt;
  
  
  The frontend: React
&lt;/h2&gt;

&lt;p&gt;For similar reasons as why we picked our backend technology, we picked React because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We know it well, and we've built earlier projects in React before.&lt;/li&gt;
&lt;li&gt;The ecosystem is mature, with a lot of plugins and tools.&lt;/li&gt;
&lt;li&gt;For an application of any size bigger than 100 lines, a typed language is a must, and React's support for TypeScript is great.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Technically, for a dashboard with a few pages, anything modern works - React, Vue, Svelte, whatever. Pick what you're productive in. For us, that's React.&lt;br&gt;
We're having an ongoing discussion if we should pick React or NextJS, but we're a little undecided at the moment, so we're sticking to what we know, so we're leaning towards React for now.&lt;/p&gt;

&lt;p&gt;We're keeping the frontend minimal. A landing page, a screenshot demo page, a login flow, some admin panels, a dashboard showing API usage and managing keys, and for our advanced users an audit log and several enterprise features. Our initial design covers around 10 pages in total. We don't have a need for complex state management, and focus on the functionality for now.&lt;/p&gt;

&lt;p&gt;Of course, we'll use Vite for the build tooling because it's fast and simple. Also, we use Tailwind for styling because it allows us to create a presentable &lt;br&gt;
design in a short amount of time without spending a large amount of time on dealing with CSS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database: Postgres
&lt;/h2&gt;

&lt;p&gt;This one was easy, and we had zero hesitation in choosing Postgres. Postgres is the right default for almost everything.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rock solid reliability&lt;/li&gt;
&lt;li&gt;Great tooling and hosting options&lt;/li&gt;
&lt;li&gt;We already know it can handle non-relational data if we need it&lt;/li&gt;
&lt;li&gt;JSON columns for flexible data when we don't want to add migrations&lt;/li&gt;
&lt;li&gt;Excellent performance for our scale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We considered going "serverless" with something like a hosted version of Postgres like PlanetScale or Neon. But a managed Postgres on our VPS or a cheap managed instance keeps things simple and predictable and avoids things like cold starts, connection pooling complexity, and potential other surprises. &lt;/p&gt;

&lt;h2&gt;
  
  
  The stuff we're not using
&lt;/h2&gt;

&lt;p&gt;Worth mentioning what we deliberately skipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kubernetes&lt;/strong&gt; - Great when you have a big team, but at the moment, it's overkill for us. A single VPS or two will handle our load for a long time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Microservices&lt;/strong&gt; - While we appreciate the concept of microservices, we're aiming to keep things simple, so we're going for a modular Monolith, until there's really a compelling reason to switch to a different architecture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GraphQL&lt;/strong&gt; - REST is simpler for this use case, and we have a well-defined API, so at the moment, we think GraphQL would be overkill&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NoSQL&lt;/strong&gt; - No see no benefit over Postgres for our data model, plus if we need to, Postgres is a pretty good NoSQL database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless functions&lt;/strong&gt; - Headless browsers and serverless are not a great combination. We're aiming for performance, and things like cold starts isn't great for our latency requirements, plus a lot of serverless functions have a maximum duration, which isn't great for our use case.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we set up today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Created the GitHub repository (monorepo: &lt;code&gt;/api&lt;/code&gt;, &lt;code&gt;/web&lt;/code&gt;, &lt;code&gt;/infra&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Initialized the Spring Boot project with Kotlin, added basic dependencies&lt;/li&gt;
&lt;li&gt;Initialized the React project with Vite and Tailwind&lt;/li&gt;
&lt;li&gt;Set up a basic GitHub Actions workflow: build and test on every push&lt;/li&gt;
&lt;li&gt;Wrote a placeholder README documenting our stack choices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No running app yet. But the skeleton is there, our CI is green (for now...), and tomorrow we can start writing actual features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tomorrow: architecture sketch
&lt;/h2&gt;

&lt;p&gt;Day 3 we'll draw out how the pieces fit together. How does a screenshot request flow through the system? Where do the images go? What happens when things fail?&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;As before, we have a small book recommendation. As we mentioned before, we use Postgres for everything, and we often say "just use Postgres", and you'll be fine. Well, it seems Joel and Aaron agree with us, and they wrote a book about it with the great title "Just Use Postgres".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.amazon.com/Just-Use-Postgres-Joel-Clermont/dp/B0F1B7WS3J?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;Just Use Postgres&lt;/a&gt; by Joel Clermont &amp;amp; Aaron Saray&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This book landed at the perfect time. The core argument: Postgres can do far more than most developers realize, and reaching for additional tools often adds complexity without real benefit.&lt;/p&gt;

&lt;p&gt;Queues? Postgres can do it. Full-text search? Postgres handles it. JSON documents? Postgres has you covered. Cron jobs? Yep. These days, Postgres even handles&lt;br&gt;
Vector Database pretty well, so in the age of AI, Postgres just became even better.&lt;/p&gt;

&lt;p&gt;Please note: we're not saying never use specialized tools. A product like Redis or Elasticsearch are great tools! But for a bootstrapped SaaS trying to keep costs and complexity low, Postgres as the foundation makes a lot of sense to us. This book reinforced that instinct and showed us features we didn't know existed.&lt;/p&gt;

&lt;p&gt;If you're defaulting to "Postgres for data, Redis for queues, Elasticsearch for search" without questioning it and want to benefit from some simplification, this book is worth a read.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 3 (1 yesterday + 2 today)&lt;/li&gt;
&lt;li&gt;Lines of code: ~50 (boilerplate)&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;li&gt;Repository created: ✓&lt;/li&gt;
&lt;li&gt;CI pipeline: ✓&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>kotlin</category>
      <category>programming</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Day 1: Why We're Building a Screenshot API</title>
      <dc:creator>Erik</dc:creator>
      <pubDate>Thu, 01 Jan 2026 11:55:41 +0000</pubDate>
      <link>https://forem.com/allscreenshots/day-1-why-were-building-a-screenshot-api-3h65</link>
      <guid>https://forem.com/allscreenshots/day-1-why-were-building-a-screenshot-api-3h65</guid>
      <description>&lt;p&gt;&lt;strong&gt;The 30-day challenge begins.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Hi! We're two developers, Erik and Illia, spread out in different continents but sharing a combined passion of delivering amazing solutions. Today we're starting a completely new project, and we'd like to do things a bit differently this time. So, instead of building in the dark and focusing on the technology, we will be building a SaaS from scratch and do so in the open, sharing our lessons learned, taking input on the way, and we're aiming to getting our first paying customer in 30 days. We'll document everything: the wins, the frustrations, parts of the actual code, and the real costs.&lt;/p&gt;

&lt;p&gt;The product we're developing is a &lt;a href="https://allscreenshots.com" rel="noopener noreferrer"&gt;screenshot API&lt;/a&gt;. It's not like there aren't solutions out there already, which is one of the reasons why we're building it. This might sound counterintuitive, but it's harder to build something completely unique, plus we already know there is a market. There is a &lt;a href="https://www.youtube.com/watch?v=67zh8_yiPh4" rel="noopener noreferrer"&gt;Starter Story&lt;/a&gt; about someone who was very successful doing exactly this. Of course our product will be slightly different than existing solutions, and we'd like to take you on this journey with us.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem we kept running into
&lt;/h2&gt;

&lt;p&gt;We value quality. One of the ways to accomplish this is by rigorous testing. In our experience, some tests are easier to write than others. For example, deterministic unit tests are easier to write than integration tests, and integration tests are easier to write than end-to-end tests. The most time-consuming tests are end-to-end tests, and they're also the most flaky, especially when they deal with UI and styling.&lt;/p&gt;

&lt;p&gt;As part of one of our previous projects, every time we did a new deployment of our app, we would automatically make a screenshot using a small script, send it to a Telegram channel, and manually inspect it. While this may seem laborious, it was a lifesaver for some of our deployments, which technically succeeded, but which introduced visual regression, for example when a new version of a library changed the layout of a page, or we introduced a more strict &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP" rel="noopener noreferrer"&gt;CSP policy&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;So, while we had a process in place, it was still a manual step, and it was still time-consuming, and our setup was limited. So, we wanted to do better. We had a few options in mind. &lt;/p&gt;

&lt;p&gt;The options were:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build it ourselves&lt;/strong&gt; - Spin up Puppeteer, manage headless Chrome, deal with memory leaks, handle timeouts, figure out why some pages render blank. It usually works until it doesn't, and then we're debugging browser automation instead of building our actual product.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use an existing service&lt;/strong&gt; - There are good ones out there. But they're often expensive for small projects, or they lock you into pricing tiers that don't match how you want to use them, or they miss some of the features we were looking for. We'll dive more into these later.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We wanted something in the middle: a reliable, fast, affordable API that just works. So we're building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we're actually making
&lt;/h2&gt;

&lt;p&gt;The product we're building will be focused around automation, and at the core it will be an API which can be used to generate screenshots of websites. That's the core. But the details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multiple devices&lt;/strong&gt; - Mobile, tablet, desktop viewports with proper emulation and real devices&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; - Our aim is to make screenshots with a minimal overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flexible&lt;/strong&gt; - Full page captures, specific elements, custom wait conditions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cheap to run&lt;/strong&gt; - We're bootstrapping this, so our infra costs need to stay minimal&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer-friendly&lt;/strong&gt; - Good docs, predictable behavior, clear error messages&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why document this publicly?
&lt;/h2&gt;

&lt;p&gt;There are a few reasons to document this publicly. One of the reasons is to keep ourselves accountable. Another reason is to&lt;br&gt;
get super early feedback. We'd love to hear from you, what we're right, and especially the things we're doing wrong!&lt;br&gt;
There are a lot of "I built a SaaS" (and now I'm earning $ 100k per month) posts. A lot of these posts are lacking details,&lt;br&gt;
lessons learned, and sometimes evidence. We want to do better than that, and take you on our journey!&lt;/p&gt;

&lt;p&gt;And, of course, this is partly marketing. Recently, we read &lt;a href="https://amzn.to/4aCfSUh" rel="noopener noreferrer"&gt;Traction: How Any Startup Can Achieve Explosive Customer Growth&lt;/a&gt;. &lt;br&gt;
While a lot of developers focus on building the product, this book is a reminder that the marketing (Traction) side of things&lt;br&gt;
is just as important as the product side. &lt;/p&gt;

&lt;p&gt;So, we hop that if this series, and our product, resonates with developers, some of you might become customers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraints we're setting
&lt;/h2&gt;

&lt;p&gt;To keep this realistic and replicable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;30 days&lt;/strong&gt; - Some days might be more hectic than others, but we're aiming to deliver as much as we can. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal budget&lt;/strong&gt; - Targeting under $20/month for hosting and infrastructure. While we love AWS and other cloud providers, the costs are too high for us to justify it at this moment. So, no expensive services until our revenue justifies it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tech we know&lt;/strong&gt; - While the technology is really not that important at this stage (use PHP and jQuery if that's what you're familiar with), we Kotlin with Spring Boot for the backend. We use React for the frontend, Postgres for the database. Maybe none of this is the trendiest stack, but we picked this because we are very familiar with it, so we can move fast in it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship ugly&lt;/strong&gt; - The landing page will be very basic. Our dashboards, if any, will be minimal, and some (most?) of our features will be incomplete. That is intentional. While we aim to move fast and not break things, we might make a mistake here and there, so please bear with us. We're optimizing for learning, not to deliver the most polished solution at this stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's happening tomorrow
&lt;/h2&gt;

&lt;p&gt;Tomorrow, we'll dig a bit more into our tech stack decision. Why Kotlin and Spring Boot, and why not tech X? Why Postgres when we could go serverless? Do we even need a database? What are the trade-offs we're consciously making? So, we hope you'll join us for tomorrow. To stay up to date with our ramblings,&lt;br&gt;
please subscribe to our newsletter, or follow us on &lt;a href="https://x.com/epragt" rel="noopener noreferrer"&gt;Twitter&lt;/a&gt;. Sorry, X.&lt;/p&gt;

&lt;h2&gt;
  
  
  Book of the day
&lt;/h2&gt;

&lt;p&gt;Before we leave you, we'd like to recommend a book that we've read alongside building this application. The book is: &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.amazon.com/Traction-Startup-Achieve-Explosive-Customer/dp/1591848369?&amp;amp;linkCode=ll1&amp;amp;tag=allscreens-20" rel="noopener noreferrer"&gt;Traction: How Any Startup Can Achieve Explosive Customer Growth&lt;/a&gt; by Gabriel Weinberg&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We've finished reading this just before we started building. Weinberg's core idea is that most startups don't fail because of their product; they fail because of distribution. Building something great isn't enough; you need channels to reach customers, and in the Traction book, Gabriel outlines 19 traction channels and a framework for testing them. &lt;/p&gt;

&lt;p&gt;We'll be experimenting with a few during this 30-day sprint: content marketing (this blog series), community engagement (Indie Hackers, Dev.to), some direct outreach, and much more! &lt;/p&gt;

&lt;p&gt;So, if you're building something and haven't read this book, we'd recommend it, we think it's worth your time.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Current stats:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hours spent: 1 (planning and writing this)&lt;/li&gt;
&lt;li&gt;Lines of code: 0&lt;/li&gt;
&lt;li&gt;Revenue: $0&lt;/li&gt;
&lt;li&gt;Paying customers: 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;See you tomorrow.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>learning</category>
      <category>cloud</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
