<?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: Maxim Osovsky</title>
    <description>The latest articles on Forem by Maxim Osovsky (@osovsky).</description>
    <link>https://forem.com/osovsky</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%2F3784819%2F78eb522b-edea-4499-a8a8-d82488d6223f.jpeg</url>
      <title>Forem: Maxim Osovsky</title>
      <link>https://forem.com/osovsky</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/osovsky"/>
    <language>en</language>
    <item>
      <title>How I Designed a Content Distribution Pipeline — 9 Content Types, 20+ Platforms, One Interactive Diagram</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Thu, 05 Mar 2026 20:38:42 +0000</pubDate>
      <link>https://forem.com/osovsky/how-i-designed-a-content-distribution-pipeline-9-content-types-20-platforms-one-interactive-lef</link>
      <guid>https://forem.com/osovsky/how-i-designed-a-content-distribution-pipeline-9-content-types-20-platforms-one-interactive-lef</guid>
      <description>&lt;h2&gt;
  
  
  The Copy-Paste Problem
&lt;/h2&gt;

&lt;p&gt;Every time I publish a &lt;a href="https://dev.to/osovsky"&gt;dev.to&lt;/a&gt; article, I share it on &lt;a href="https://twitter.com/maximosovsky" rel="noopener noreferrer"&gt;X&lt;/a&gt; and &lt;a href="https://mastodon.social/@osovsky" rel="noopener noreferrer"&gt;Mastodon&lt;/a&gt; — but why not also &lt;a href="https://bsky.app/profile/osovsky.bsky.social" rel="noopener noreferrer"&gt;Bluesky&lt;/a&gt; and &lt;a href="https://www.threads.com/@maxim.osovsky" rel="noopener noreferrer"&gt;Threads&lt;/a&gt;? I keep meaning to, but it's manual work every time.&lt;/p&gt;

&lt;p&gt;My podcast goes to &lt;a href="https://podcasts.apple.com/ru/podcast/%D0%BC%D0%B0%D0%BA%D1%81%D0%B8%D0%BC-%D0%BE%D1%81%D0%BE%D0%B2%D1%81%D0%BA%D0%B8%D0%B9-%D0%BF%D1%80%D0%BE-%D0%BC%D1%8B%D1%88%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5/id1839077039" rel="noopener noreferrer"&gt;Apple Podcasts&lt;/a&gt; — but I want it on &lt;a href="https://open.spotify.com/show/2bR6D4BcymHjtYgU8hrYOG" rel="noopener noreferrer"&gt;Spotify&lt;/a&gt; and &lt;a href="https://www.youtube.com/@maximosovsky" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt; Podcasts too. I just never get around to setting it up for each episode.&lt;/p&gt;

&lt;p&gt;When I shoot a vertical video, it goes to &lt;a href="https://www.tiktok.com/@osovski" rel="noopener noreferrer"&gt;TikTok&lt;/a&gt; — but I skip &lt;a href="https://instagram.com/maxim.osovsky" rel="noopener noreferrer"&gt;Instagram&lt;/a&gt; Reels, YouTube Shorts, and &lt;a href="https://facebook.com/maxim.osovsky" rel="noopener noreferrer"&gt;Facebook&lt;/a&gt; Stories. Same content, different platforms, and I don't have the time to upload it four times.&lt;/p&gt;

&lt;p&gt;And when I create an event — it's a 2-hour ordeal. &lt;a href="https://luma.com/user/osovsky" rel="noopener noreferrer"&gt;Luma&lt;/a&gt;, &lt;a href="https://linkedin.com/in/osovsky" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; Events, Facebook Events, &lt;a href="https://www.meetup.com/members/136721232/" rel="noopener noreferrer"&gt;Meetup&lt;/a&gt;. Each one needs its own format, its own description, its own image.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;20+ platforms&lt;/strong&gt;. For &lt;strong&gt;9 different content types&lt;/strong&gt;. And I'm doing maybe half of it manually, skipping the rest.&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%2Fmz7spwov4zg1pbgyuwbt.JPG" 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%2Fmz7spwov4zg1pbgyuwbt.JPG" alt="Maxim Osovsky mapping content distribution during a strategy session" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;During one of the strategy sessions with my team, I mapped out every content type I produce, every platform I post to, and every connection between them. The result was a mess of arrows on a whiteboard. So I turned it into code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Building
&lt;/h2&gt;

&lt;p&gt;An &lt;strong&gt;open-source content distribution hub&lt;/strong&gt; — one dashboard that handles all 9 content types across 20+ platforms. Write in Markdown, and the system routes everything to the right platforms automatically.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Details&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Content types&lt;/td&gt;
&lt;td&gt;9 (articles, book chapters, photos, vertical/horizontal video, podcasts, events)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platforms&lt;/td&gt;
&lt;td&gt;20+ (LinkedIn, Medium, Dev.to, Substack, YouTube, X, Mastodon, Bluesky, Threads, TikTok, Instagram, Facebook, Reddit, Luma, Meetup, Spotify, Apple Podcasts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0&lt;/strong&gt; (self-hosted on &lt;a href="https://vercel.com" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack&lt;/td&gt;
&lt;td&gt;Vanilla JS + &lt;a href="https://vercel.com" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt; Serverless + &lt;a href="https://upstash.com" rel="noopener noreferrer"&gt;Upstash Redis&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The project is called &lt;strong&gt;Autoposting Dashboard&lt;/strong&gt; and it's open source: &lt;a href="https://github.com/maximosovsky/avtoposting" rel="noopener noreferrer"&gt;github.com/maximosovsky/avtoposting&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Diagram That Maps It All
&lt;/h2&gt;

&lt;p&gt;Before writing any API integration, I needed to see the &lt;strong&gt;full picture&lt;/strong&gt;. The first attempt was a &lt;a href="https://mermaid.js.org/" rel="noopener noreferrer"&gt;Mermaid&lt;/a&gt; diagram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;graph LR
    Write --&amp;gt; Storage --&amp;gt; Dashboard --&amp;gt; Publish --&amp;gt; API --&amp;gt; Crosspost --&amp;gt; Track
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mermaid was great for a quick sketch, but it fell apart with 50+ nodes and complex routing. I couldn't control layout, colors, or icons. So I switched to &lt;strong&gt;custom HTML + SVG&lt;/strong&gt; — a single &lt;code&gt;diagram.html&lt;/code&gt; file with pan, zoom, and real connection lines drawn via JavaScript.&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%2F0pxrvhi9k8z7whv3t7hy.jpg" 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%2F0pxrvhi9k8z7whv3t7hy.jpg" alt="Autoposting pipeline — first Mermaid diagram version by Maxim Osovsky" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Then the icons broke.&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%2Fkhome0n2d8bzab5ekqsq.jpg" 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%2Fkhome0n2d8bzab5ekqsq.jpg" alt="Autoposting diagram with broken SVG icons — version 1 by Maxim Osovsky" width="800" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I was loading platform SVGs from &lt;a href="https://simpleicons.org/" rel="noopener noreferrer"&gt;Simple Icons&lt;/a&gt; via CDN — LinkedIn, Medium, Dev.to, YouTube, etc. Some rendered fine. Some didn't. Some loaded on Chrome but not Firefox. Some showed as blank squares.&lt;/p&gt;

&lt;p&gt;The fix was boring but effective: &lt;strong&gt;download all 21 icons&lt;/strong&gt; into a local &lt;code&gt;icons/&lt;/code&gt; folder and reference them directly. No CDN, no CORS, no surprises.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;icons/
├── bluesky-0085FF.svg
├── devdotto-0A0A0A.svg
├── googledrive-4285F4.svg
├── luma.svg
├── mastodon-6364FF.svg
├── medium-000000.svg
├── meetup-ED1C40.svg
├── youtube-FF0000.svg
└── ... (21 total)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The final diagram visualizes the entire 8-stage pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⓪ Media → ① Write → ② Storage → ③ Dashboard → ④ Publish → ⑤ API → ⑥ Cross-post → ⑦ Track
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every node is clickable. Every connection is a real data flow. Every content type has its own path through the pipeline.&lt;/p&gt;

&lt;p&gt;Building this diagram with &lt;a href="https://blog.google/technology/google-deepmind/gemini-ai-update-august-2024/" rel="noopener noreferrer"&gt;Antigravity&lt;/a&gt; (Google DeepMind's AI coding assistant) took one intensive session. We went through &lt;strong&gt;30 questions&lt;/strong&gt; in 3 rounds of 10, challenging every assumption about content routing, media storage, and platform dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 9 Content Types and Where They Go
&lt;/h2&gt;

&lt;p&gt;Here's the routing logic for each content type — the "why" behind every arrow in the diagram.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. LinkedIn Article → 4 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write .md → LinkedIn (publish) → X, Mastodon, Bluesky, Threads (share link)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LinkedIn is the &lt;strong&gt;primary platform&lt;/strong&gt; for business content. After publishing, the LinkedIn URL gets shared to 4 social platforms. Each platform's API script (&lt;code&gt;twitter.js&lt;/code&gt;, &lt;code&gt;mastodon.js&lt;/code&gt;, etc.) handles both link posts and media uploads — a &lt;strong&gt;dual-mode approach&lt;/strong&gt; we chose over separate scripts.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;a href="https://osovsky.medium.com/" rel="noopener noreferrer"&gt;Medium&lt;/a&gt; Article → 4 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write .md → Medium (publish) → X, Mastodon, Bluesky, Threads (share link)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern as LinkedIn, different audience. Medium for philosophy and science essays.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Dev.to Article → 4 socials + &lt;a href="https://www.reddit.com/user/maximosovsky/" rel="noopener noreferrer"&gt;Reddit&lt;/a&gt;
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write .md → Dev.to (publish) → X, Mastodon, Bluesky, Threads (share link)
                             → Reddit (full article copy, subreddit by tags)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev.to is the &lt;strong&gt;flagship&lt;/strong&gt; for build-in-public content. &lt;a href="https://www.reddit.com/user/maximosovsky/" rel="noopener noreferrer"&gt;Reddit&lt;/a&gt; gets a &lt;strong&gt;full copy&lt;/strong&gt; (not just a link) — subreddit is auto-selected based on article tags.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Book Chapters → &lt;a href="https://substack.com/@osovsky" rel="noopener noreferrer"&gt;Substack&lt;/a&gt; → LinkedIn, Medium → 4 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write .md → Substack (publish first)
         → LinkedIn, Medium (share Substack link)
         → X, Mastodon, Bluesky, Threads (share Substack link)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Book chapters publish to &lt;a href="https://substack.com/@osovsky" rel="noopener noreferrer"&gt;Substack&lt;/a&gt; first. Then the Substack link cascades to LinkedIn, Medium, and 4 socials. &lt;strong&gt;Sequential, not parallel&lt;/strong&gt; — Substack is the source of truth.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Photo + Text → 6 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload photo → Facebook, Instagram (direct post)
            → X, Mastodon, Bluesky, Threads (media upload)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No link sharing needed — the photo and its caption are the content. Same image and text uploaded directly to all 6 platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Vertical Video → 4 platforms
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload 9:16 video → YouTube Shorts, TikTok, Instagram Reels, Facebook Stories
                  → X, Mastodon, Bluesky, Threads (share YouTube Shorts link)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vertical video (≤512MB, 9:16 aspect ratio) goes to all short-form video platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Horizontal Video → YouTube → 4 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Upload 16:9 video → YouTube (publish)
                  → X, Mastodon, Bluesky, Threads (share YouTube link)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Standard YouTube flow. Cover image (thumbnail) stored alongside the video metadata.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Podcast → Podster.fm → Apple, Spotify, YouTube → 4 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write .md (audio_url in frontmatter) → Podster.fm (RSS distribution)
                                     → Apple Podcasts, Spotify, YouTube Podcasts
                                     → X, Mastodon, Bluesky, Threads
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audio files are referenced via &lt;code&gt;audio_url&lt;/code&gt; in the Markdown frontmatter. &lt;a href="https://podster.fm/user/osovsky" rel="noopener noreferrer"&gt;Podster.fm&lt;/a&gt; handles RSS distribution to all podcast platforms.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Events → Luma → 4 event platforms + Instagram → 4 socials
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create event → Luma (publish first)
            → LinkedIn Events, Facebook Events, Meetup.com, Instagram (share Luma link)
            → X, Mastodon, Bluesky, Threads (share Luma link)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events always publish to &lt;a href="https://lu.ma" rel="noopener noreferrer"&gt;Luma&lt;/a&gt; first — then the Luma link fans out to all event platforms and socials.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The project is open source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/maximosovsky/avtoposting.git
&lt;span class="nb"&gt;cd &lt;/span&gt;avtoposting
npm i &lt;span class="nt"&gt;-g&lt;/span&gt; vercel
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# fill in API keys&lt;/span&gt;
vercel dev &lt;span class="nt"&gt;--listen&lt;/span&gt; 3000
&lt;span class="c"&gt;# Open http://localhost:3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;diagram.html&lt;/code&gt; in a browser to explore the full pipeline interactively.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building something similar? Drop a comment — I'd love to compare approaches.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://linkedin.com/in/osovsky" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://dev.to/osovsky"&gt;Dev.to&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>opensource</category>
      <category>contentcreation</category>
      <category>automation</category>
    </item>
    <item>
      <title>I Was Building a Cloud Video Service. YouTube Turned Me Into an IP Trafficker.</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Tue, 03 Mar 2026 16:54:00 +0000</pubDate>
      <link>https://forem.com/osovsky/i-was-building-a-cloud-video-service-youtube-turned-me-into-an-ip-trafficker-1l9o</link>
      <guid>https://forem.com/osovsky/i-was-building-a-cloud-video-service-youtube-turned-me-into-an-ip-trafficker-1l9o</guid>
      <description>&lt;h2&gt;
  
  
  The Irony
&lt;/h2&gt;

&lt;p&gt;I was building a simple cloud service. Paste YouTube links, server merges them, you get a video. Innocent. Wholesome. The kind of project you'd show your mom.&lt;/p&gt;

&lt;p&gt;Then YouTube said no.&lt;/p&gt;

&lt;p&gt;Not because I was doing anything wrong. Not because I violated their API terms. But because my server's IP address came from a datacenter instead of someone's apartment.&lt;/p&gt;

&lt;p&gt;So now, to make this work, I'll have to buy residential IP traffic from a proxy provider, route my downloads through random households, and pray that YouTube's bot detection doesn't notice. My "simple video merging service" is quietly becoming a &lt;strong&gt;proxy arbitrage operation&lt;/strong&gt; that will live in the gray zone of YouTube's Terms of Service.&lt;/p&gt;

&lt;p&gt;This is that story.&lt;/p&gt;




&lt;h2&gt;
  
  
  Context: What I'm Building
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt; is an open-source service I'm building to merge video files in the cloud. The idea: you send links or upload files, go to sleep, and wake up to a merged video on YouTube with a link in your email.&lt;/p&gt;

&lt;p&gt;I film long strategy sessions — 6 to 8 hours each. My camera records in 20-minute segments. After each session I have dozens of files that need to be joined. I spent over &lt;strong&gt;$1,500&lt;/strong&gt; outsourcing this before building the service myself.&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%2Fsxfr03oirwpgsmn88sjw.jpg" 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%2Fsxfr03oirwpgsmn88sjw.jpg" alt="Maxim Osovsky during a strategy session" width="800" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's the thing: after a session, I rarely have time to edit. So I upload the raw segments straight to YouTube — unmerged, unedited — just to free up disk space. YouTube becomes my storage. Over the years I've accumulated &lt;strong&gt;hundreds of raw clips&lt;/strong&gt; organized into playlists, each playlist representing one session that still needs to be merged into a single video for the archive.&lt;/p&gt;

&lt;p&gt;That's why the "YouTube URLs" mode exists. It will let you paste a playlist link and the server will download all the clips, merge them, and upload the result back as one video. It worked perfectly on localhost. On the cloud server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Sign in to confirm you're not a bot.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project-oon"&gt;Part 1&lt;/a&gt;, I solved the ffmpeg bugs. In &lt;a href="https://dev.to/osovsky/6-ways-to-get-youtube-cookies-for-yt-dlp-in-2026-only-1-works-2cnb"&gt;Part 4&lt;/a&gt;, I solved cookie extraction. This is Part 5: getting downloads to actually work from a server.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Real Problem: It's the IP, Not the Cookies
&lt;/h2&gt;

&lt;p&gt;After extracting cookies (see Part 4), I uploaded &lt;code&gt;cookies.txt&lt;/code&gt; to the server. Same &lt;code&gt;yt-dlp&lt;/code&gt; command that worked on localhost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yt-dlp &lt;span class="nt"&gt;--cookies&lt;/span&gt; cookies.txt &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"best[ext=mp4]"&lt;/span&gt; &lt;span class="s2"&gt;"https://youtu.be/VIDEO_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result on localhost:&lt;/strong&gt; ✅ Downloaded in 30 seconds&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result on cloud server:&lt;/strong&gt; ❌ Same bot detection error&lt;/p&gt;

&lt;p&gt;The cookies were valid. The command was identical. The only difference: &lt;strong&gt;the IP address&lt;/strong&gt;. My laptop has a residential IP from my ISP. The server has a datacenter IP from &lt;a href="https://www.alibabacloud.com/" rel="noopener noreferrer"&gt;Alibaba Cloud&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;YouTube doesn't just check cookies. It checks &lt;strong&gt;where the request comes from&lt;/strong&gt;. Datacenter IPs are flagged automatically — no amount of cookies, headers, or user-agent strings will help.&lt;/p&gt;




&lt;h2&gt;
  
  
  7 Methods Tested
&lt;/h2&gt;

&lt;p&gt;I spent two days testing every viable approach. Here's the full scorecard:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;th&gt;Result&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;
&lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt; + cookies&lt;/td&gt;
&lt;td&gt;Direct download with browser cookies&lt;/td&gt;
&lt;td&gt;❌ IP blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/imputnet/cobalt" rel="noopener noreferrer"&gt;Cobalt API&lt;/a&gt; (public)&lt;/td&gt;
&lt;td&gt;Third-party download API&lt;/td&gt;
&lt;td&gt;❌ Requires Turnstile CAPTCHA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Self-hosted &lt;a href="https://github.com/imputnet/cobalt" rel="noopener noreferrer"&gt;Cobalt&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;Run your own Cobalt instance&lt;/td&gt;
&lt;td&gt;❌ Signature decipher broken&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://rapidapi.com/" rel="noopener noreferrer"&gt;RapidAPI&lt;/a&gt; YouTube downloaders&lt;/td&gt;
&lt;td&gt;Commercial download APIs&lt;/td&gt;
&lt;td&gt;❌ Rate-limited, unreliable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://savefrom.net/" rel="noopener noreferrer"&gt;SaveFrom&lt;/a&gt; / similar services&lt;/td&gt;
&lt;td&gt;Web-based download tools&lt;/td&gt;
&lt;td&gt;❌ Quality limits, no API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;YouTube Data API direct download&lt;/td&gt;
&lt;td&gt;Google's official API&lt;/td&gt;
&lt;td&gt;❌ No download endpoint exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Residential proxy + yt-dlp&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Route traffic through residential IPs&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;Works&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every method that tries to download from a datacenter IP fails. The only solution is to &lt;strong&gt;not have a datacenter IP&lt;/strong&gt; — or to commit to the charade of pretending you don't.&lt;/p&gt;

&lt;p&gt;Let me walk you through the graveyard.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 1: yt-dlp + Cookies (Failed)
&lt;/h2&gt;

&lt;p&gt;The setup from Part 2. Cookies extracted from Firefox, uploaded to server, &lt;code&gt;yt-dlp&lt;/code&gt; configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yt-dlp &lt;span class="nt"&gt;--cookies&lt;/span&gt; cookies.txt &lt;span class="nt"&gt;--js-runtimes&lt;/span&gt; node &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"best[ext=mp4]"&lt;/span&gt; &lt;span class="s2"&gt;"https://youtu.be/VIDEO_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On localhost with a residential IP: instant download. On the cloud server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Sign in to confirm you're not a bot.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Diagnosis:&lt;/strong&gt; YouTube allows or blocks based on IP reputation &lt;strong&gt;before&lt;/strong&gt; even checking cookies. If the IP is flagged as datacenter, the request is rejected at the network level.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 2: Cobalt API (Failed)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/imputnet/cobalt" rel="noopener noreferrer"&gt;Cobalt&lt;/a&gt; is a popular open-source media downloader. Their public API at &lt;code&gt;api.cobalt.tools&lt;/code&gt; seemed perfect — offload the download to their infrastructure.&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 &lt;span class="s2"&gt;"https://api.cobalt.tools"&lt;/span&gt; &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://youtu.be/VIDEO_ID"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; &lt;code&gt;error.api.auth.turnstile.missing&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Cobalt added &lt;a href="https://www.cloudflare.com/products/turnstile/" rel="noopener noreferrer"&gt;Cloudflare Turnstile&lt;/a&gt; CAPTCHA to prevent automated API abuse. Every request now requires solving a browser challenge — impossible from a server script.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 3: Self-Hosted Cobalt (Failed)
&lt;/h2&gt;

&lt;p&gt;If the public API requires CAPTCHA, run your own instance. I deployed Cobalt via Docker on the server:&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;cobalt-api&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/imputnet/cobalt:10&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;9000:9000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;API_AUTH_REQUIRED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API responded, accepted URLs, and started processing. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: ErrorCantProcess - couldn't process your request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Diagnosis:&lt;/strong&gt; Cobalt uses its own YouTube signature deciphering algorithm. YouTube frequently changes their obfuscation, and the self-hosted version had a stale decipher implementation. The maintainers patch the public instance frequently, but a self-hosted snapshot falls behind within days.&lt;/p&gt;




&lt;h2&gt;
  
  
  Methods 4–6: Commercial APIs and Direct Downloads (All Failed)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Why it failed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;RapidAPI downloaders&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Rate limits, intermittent 403s, quality caps. Most are wrappers around the same broken approaches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SaveFrom / y2mate / similar&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Designed for browser use. No reliable API, quality limits, heavy ads, services go down often&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;YouTube Data API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Google's official API has endpoints for metadata, search, and uploads — but &lt;strong&gt;no download endpoint&lt;/strong&gt;. By design.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these solve the core problem: YouTube blocks server-originating requests. The entire ecosystem of "YouTube downloader" services is a game of whack-a-mole against Google's engineering team. And Google has more engineers.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 7: Residential Proxy ✅
&lt;/h2&gt;

&lt;p&gt;The only approach that worked. And the one I was hoping to avoid.&lt;/p&gt;

&lt;p&gt;To download a YouTube video from a cloud server, you need to pretend your server is someone's laptop in a suburban apartment. That's what residential proxies do. That's what I now have to pay for monthly. Welcome to the internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's a residential proxy?
&lt;/h3&gt;

&lt;p&gt;Instead of your request going directly from the server (datacenter IP) to YouTube, it goes through a proxy server that uses an IP assigned by a real ISP to a real household. YouTube sees a residential IP and treats it as a normal user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server (datacenter IP) → Proxy (residential IP) → YouTube
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Setup
&lt;/h3&gt;

&lt;p&gt;I chose &lt;a href="https://decodo.com/proxies/residential-proxies" rel="noopener noreferrer"&gt;Decodo&lt;/a&gt; for residential proxies — they support SOCKS5, have 115M+ IPs across 195+ countries, and offer pay-per-GB pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backend configuration:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config.py
&lt;/span&gt;&lt;span class="n"&gt;PROXY_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PROXY_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# video.py — in both expand_urls() and download_videos()
&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yt-dlp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--cookies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COOKIES_FILE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--js-runtimes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;best[ext=mp4]&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;PROXY_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--proxy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PROXY_URL&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;Environment:&lt;/strong&gt;&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;# .env on the server&lt;/span&gt;
&lt;span class="nv"&gt;PROXY_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;socks5h://user-USERNAME-session-1:PASSWORD@gate.proxy-provider.com:7000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Dependencies:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;pysocks  &lt;span class="c"&gt;# Required for SOCKS5 proxy support&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PySocks&lt;/code&gt; is required for yt-dlp to work with SOCKS5 proxies.&lt;/p&gt;

&lt;h3&gt;
  
  
  First successful test
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yt-dlp &lt;span class="nt"&gt;--proxy&lt;/span&gt; &lt;span class="s2"&gt;"socks5h://user-USERNAME-session-1:PASSWORD@gate.proxy-provider.com:7000"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cookies&lt;/span&gt; cookies.txt &lt;span class="nt"&gt;--js-runtimes&lt;/span&gt; node &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"best[ext=mp4]"&lt;/span&gt; &lt;span class="s2"&gt;"https://youtu.be/VIDEO_ID"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[youtube] Extracting URL: https://youtu.be/VIDEO_ID
[youtube] VIDEO_ID: Downloading webpage
[youtube] VIDEO_ID: Downloading ios player API JSON
[download] Destination: video.mp4
[download] 100% of 403.12MiB in 00:02:15
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It worked.&lt;/strong&gt; Same server, same datacenter IP, same cookies. The only change: traffic routed through a residential proxy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Analysis: Congratulations, You're a Bandwidth Broker
&lt;/h2&gt;

&lt;p&gt;Here's where the absurdity becomes quantifiable. I wanted to build a free, open-source tool. Instead I'll have a &lt;strong&gt;variable cost per download&lt;/strong&gt; that scales with video length. Every time someone pastes a YouTube link into my service, I'll have to purchase residential bandwidth from a proxy provider and route it through some stranger's home internet connection.&lt;/p&gt;

&lt;p&gt;Let that sink in.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&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;Residential proxy plan (2 GB)&lt;/td&gt;
&lt;td&gt;$12/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pay-as-you-go rate&lt;/td&gt;
&lt;td&gt;$3.50/GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical video download (17 min, 1080p)&lt;/td&gt;
&lt;td&gt;~400 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two videos merged (one E2E test)&lt;/td&gt;
&lt;td&gt;~870 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Estimated downloads per 2 GB plan&lt;/td&gt;
&lt;td&gt;~5 video pairs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Cost per merge job
&lt;/h3&gt;

&lt;p&gt;At $12 for 2 GB: roughly &lt;strong&gt;$2.40 per merge&lt;/strong&gt; (assuming two ~400 MB videos).&lt;/p&gt;

&lt;p&gt;Compare with my old approach: &lt;strong&gt;$30–50 per merge&lt;/strong&gt; outsourced to a human editor.&lt;/p&gt;

&lt;p&gt;The economics work, but it means my "free self-hosted service" will have a &lt;strong&gt;variable cost per download&lt;/strong&gt; that scales with video size. Every time a user pastes a YouTube link, I'll be buying residential IP traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scaling considerations
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;$/GB&lt;/th&gt;
&lt;th&gt;Monthly cost&lt;/th&gt;
&lt;th&gt;Videos (~400 MB each)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2 GB&lt;/td&gt;
&lt;td&gt;$6.00&lt;/td&gt;
&lt;td&gt;$12&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8 GB&lt;/td&gt;
&lt;td&gt;$1.80&lt;/td&gt;
&lt;td&gt;$14.50&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;25 GB&lt;/td&gt;
&lt;td&gt;$1.70&lt;/td&gt;
&lt;td&gt;$42.90&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pay-as-you-go&lt;/td&gt;
&lt;td&gt;$3.50&lt;/td&gt;
&lt;td&gt;Variable&lt;/td&gt;
&lt;td&gt;Variable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For personal use (5–10 merges/month), the 2 GB plan is fine. For a public service, &lt;strong&gt;proxy costs will become the dominant expense&lt;/strong&gt; — not compute, not storage, not bandwidth. The most expensive part of my video merging service will be &lt;em&gt;pretending to be a regular person on the internet&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I started this project to save $1,500/year on video editing outsourcing. Now I'll be spending $144/year on residential IP traffic just to download files that are publicly available on YouTube. The economics work, but the irony is thick enough to cut.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Strategic Lesson
&lt;/h2&gt;

&lt;p&gt;YouTube's bot detection operates at &lt;strong&gt;two different levels&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;What's checked&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Application&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cookies, headers, user-agent, JS challenges&lt;/td&gt;
&lt;td&gt;Cookies + &lt;code&gt;--js-runtimes node&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Network&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;IP reputation (datacenter vs. residential)&lt;/td&gt;
&lt;td&gt;Residential proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I spent Part 4 solving the &lt;strong&gt;application layer&lt;/strong&gt; — extracting cookies, configuring JavaScript runtimes. All necessary, but not sufficient.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;network layer&lt;/strong&gt; is the harder barrier. YouTube maintains IP reputation databases. Datacenter IP ranges from major cloud providers (AWS, GCP, Azure, Alibaba, etc.) are automatically flagged. No amount of application-layer configuration changes this.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you avoid proxies?
&lt;/h3&gt;

&lt;p&gt;Surely there's a way to not pay strangers for their IP addresses?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alternative&lt;/th&gt;
&lt;th&gt;Feasibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Run the server from home (residential IP)&lt;/td&gt;
&lt;td&gt;✅ Works, but congrats — you've un-invented the cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Use a VPS from a residential ISP&lt;/td&gt;
&lt;td&gt;⚠️ Rare, expensive, may still get flagged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set up a VPN tunnel to your home network&lt;/td&gt;
&lt;td&gt;⚠️ Works but adds latency, depends on home uplink&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download locally, upload to server, merge&lt;/td&gt;
&lt;td&gt;⚠️ Works but defeats the purpose of cloud processing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a production service, residential proxies are the pragmatic choice. They're the &lt;strong&gt;only way to reliably download from YouTube on cloud infrastructure&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So yes — to run a cloud video service that touches YouTube, you must pay a monthly fee to disguise your cloud server as a home computer. The cloud, pretending not to be the cloud. Peak 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  Update: It Works
&lt;/h2&gt;

&lt;p&gt;While writing this article, I ran the first real end-to-end test. Two strategy session clips — 17 minutes and 26 minutes — pasted as a YouTube playlist link into my own service.&lt;/p&gt;

&lt;p&gt;The proxy downloaded 870 MB. The server spent about an hour normalizing and merging (it's a small 2 vCPU instance). Then it uploaded the result to YouTube and sent me an email: &lt;strong&gt;🎬 Your merged video is ready!&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cost of this single merge: roughly &lt;strong&gt;$6 in residential proxy traffic&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I started this project to avoid paying $30–50 per merge to a human editor. At $6 per merge, the math still works. But I never imagined that the hardest part of building a video merging service would be &lt;em&gt;convincing YouTube that my server is someone's laptop&lt;/em&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 5 of a series about building &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt;. Previous: &lt;a href="https://dev.to/osovsky/6-ways-to-get-youtube-cookies-for-yt-dlp-in-2026-only-1-works-2cnb"&gt;6 Ways to Get YouTube Cookies — Only 1 Works&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>youtube</category>
      <category>proxy</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>6 Ways to Get YouTube Cookies for yt-dlp in 2026 — Only 1 Works</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Mon, 02 Mar 2026 16:54:00 +0000</pubDate>
      <link>https://forem.com/osovsky/6-ways-to-get-youtube-cookies-for-yt-dlp-in-2026-only-1-works-2cnb</link>
      <guid>https://forem.com/osovsky/6-ways-to-get-youtube-cookies-for-yt-dlp-in-2026-only-1-works-2cnb</guid>
      <description>&lt;h2&gt;
  
  
  The Hypothesis
&lt;/h2&gt;

&lt;p&gt;I have a self-hosted video merging service — &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt;. It downloads YouTube videos with &lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt;, merges them with &lt;a href="https://ffmpeg.org/" rel="noopener noreferrer"&gt;ffmpeg&lt;/a&gt;, and uploads the result back to YouTube.&lt;/p&gt;

&lt;p&gt;On localhost everything worked. On a cloud server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: Sign in to confirm you're not a bot.
Use --cookies-from-browser or --cookies for authentication.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;YouTube detected the datacenter IP and blocked downloads. The fix seemed obvious: extract cookies from Chrome, upload to server, done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My hypothesis:&lt;/strong&gt; extracting cookies is a solved problem. Dozens of tools exist. Should take 10 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reality:&lt;/strong&gt; I spent 3 hours trying 6 different methods. Only one worked — and it wasn't Chrome.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Debugging Cookies Taught Me About Strategy
&lt;/h2&gt;

&lt;p&gt;I run &lt;a href="https://osovsky.medium.com/schem-b1810498982d" rel="noopener noreferrer"&gt;strategic sessions&lt;/a&gt; — 6-to-10-hour workshops where teams map complex systems before making decisions. We draw the object of management, analyze its place in a larger system, do hindsight and foresight, and only then build a plan. Over 150 sessions so far.&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%2F60l67r7nt62krjpz455z.jpg" 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%2F60l67r7nt62krjpz455z.jpg" alt="Maxim Osovsky during a strategy session" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The cookie debugging was the exact opposite of this process.&lt;/p&gt;

&lt;p&gt;I had an AI coding assistant — &lt;a href="https://blog.google/technology/google-deepmind/" rel="noopener noreferrer"&gt;Antigravity&lt;/a&gt; by Google DeepMind — writing scripts, testing APIs, trying approaches. It's incredibly fast. But here's what happened: we burned through 6 attempts in 3 hours because &lt;strong&gt;neither of us stopped to map the system first&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The moment I paused and asked: &lt;em&gt;"What's the plan? Pros, cons, risks, options?"&lt;/em&gt; — the answer became obvious in minutes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Without strategy&lt;/th&gt;
&lt;th&gt;With strategy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Try DPAPI → fail&lt;/td&gt;
&lt;td&gt;Map the landscape: Chrome encrypts, Firefox doesn't&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Try rookiepy → fail&lt;/td&gt;
&lt;td&gt;Identify constraints: app-bound encryption since July 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Try CDP → fail&lt;/td&gt;
&lt;td&gt;Evaluate options: only 2 viable paths (Firefox or proxies)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Try OAuth → fail&lt;/td&gt;
&lt;td&gt;Choose: Firefox = free, proxies = $1.50/GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Try Bearer token → fail&lt;/td&gt;
&lt;td&gt;Execute: 10 lines of Python, done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Try Firefox → works&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;6 attempts, 3 hours&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 attempt, 5 minutes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the pattern I see in every strategic session: teams that jump to solutions before mapping the system waste time on dead ends. Teams that invest 30 minutes in analysis often find the answer without trying a single wrong approach.&lt;/p&gt;

&lt;p&gt;AI tools are powerful executors. They'll write any script you ask for in seconds. But they'll also happily execute 5 wrong approaches before someone asks: &lt;em&gt;"Wait — what are we actually dealing with?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The human's job isn't to write code. It's to bring &lt;strong&gt;strategy&lt;/strong&gt; — to ask the right questions before the first line of code is written.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⚠️ First, a Security Warning
&lt;/h2&gt;

&lt;p&gt;Before you Google "export Chrome cookies" — read this.&lt;/p&gt;

&lt;p&gt;When I first asked Antigravity to help extract cookies, its very first suggestion was to install a popular Chrome extension — &lt;strong&gt;Get cookies.txt&lt;/strong&gt;. Quick, easy, thousands of users. I didn't install it — something felt off about giving a third-party extension access to all my browser sessions.&lt;/p&gt;

&lt;p&gt;Good instinct. That extension &lt;a href="https://www.reddit.com/r/youtubedl/comments/10ar7o7/if_youve_been_using_the_get_cookiestxt_chrome/" rel="noopener noreferrer"&gt;turned out to be malware&lt;/a&gt;. It was silently sending &lt;strong&gt;all your cookies&lt;/strong&gt; — login sessions, banking tokens, everything — to its developer. Google removed it from the Chrome Web Store and flagged it as malware.&lt;/p&gt;

&lt;p&gt;This isn't an isolated case. Any browser extension with cookie access permissions can steal your sessions. Cookie-stealing malware like &lt;a href="https://malpedia.caad.fkie.fraunhofer.de/details/win.raccoon" rel="noopener noreferrer"&gt;Raccoon&lt;/a&gt;, &lt;a href="https://malpedia.caad.fkie.fraunhofer.de/details/win.redline_stealer" rel="noopener noreferrer"&gt;RedLine&lt;/a&gt;, and &lt;a href="https://malpedia.caad.fkie.fraunhofer.de/details/win.vidar" rel="noopener noreferrer"&gt;Vidar&lt;/a&gt; has been doing exactly this for years.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The rule:&lt;/strong&gt; never install a third-party extension to export cookies. Use built-in tools or read the database directly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #1: DPAPI Decryption Script
&lt;/h2&gt;

&lt;p&gt;Chrome encrypts cookies with &lt;a href="https://learn.microsoft.com/en-us/windows/win32/seccrypto/cng-dpapi" rel="noopener noreferrer"&gt;DPAPI&lt;/a&gt; + AES-256-GCM on Windows. I wrote a Python script to call &lt;code&gt;CryptUnprotectData&lt;/code&gt; via &lt;code&gt;ctypes&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DATA_BLOB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Structure&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;_fields_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cbData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wintypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DWORD&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pbData&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;POINTER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_char&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;windll&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;crypt32&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CryptUnprotectData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_blob&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_blob&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;Result:&lt;/strong&gt; &lt;code&gt;CryptUnprotectData failed&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; &lt;a href="https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html" rel="noopener noreferrer"&gt;Chrome 127+&lt;/a&gt; (July 2024) introduced &lt;strong&gt;app-bound encryption&lt;/strong&gt;. The decryption key is now bound to the Chrome binary itself. External programs — even running as the same user — can't decrypt cookies anymore.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #2: rookiepy
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/thewh1teazt3c/rookie" rel="noopener noreferrer"&gt;rookiepy&lt;/a&gt; is a Rust-based Python library built specifically for extracting browser cookies. It handles modern Chrome encryption:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rookiepy&lt;/span&gt;
&lt;span class="n"&gt;cookies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rookiepy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.youtube.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.google.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ran it as Administrator. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; &lt;code&gt;RuntimeError: decrypt_encrypted_value failed&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; Same app-bound encryption. Even with admin privileges, no external process can access Chrome's cookie encryption key on Chrome 127+.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #3: Chrome DevTools Protocol (CDP)
&lt;/h2&gt;

&lt;p&gt;If you can't decrypt cookies externally, maybe ask Chrome directly. Chrome's &lt;a href="https://chromedevtools.github.io/devtools-protocol/" rel="noopener noreferrer"&gt;DevTools Protocol&lt;/a&gt; has &lt;code&gt;Network.getAllCookies&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Network.getAllCookies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="n"&gt;cookies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;())[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cookies&lt;/span&gt;&lt;span class="sh"&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 requires starting Chrome with &lt;code&gt;--remote-debugging-port=9222&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; &lt;code&gt;TCP connect to (127.0.0.1 : 9222) failed&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; On Windows, if Chrome is already running, a new instance with the debug flag opens as a window in the existing process — without the debugging port. I killed Chrome, restarted with the flag. Chrome still wouldn't bind to port 9222. Possibly Windows Defender or a Chrome policy blocking it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #4: yt-dlp Native OAuth2
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt; added native &lt;a href="https://github.com/yt-dlp/yt-dlp/wiki/Extractors#logging-in-with-oauth" rel="noopener noreferrer"&gt;OAuth2 support&lt;/a&gt; in 2024. It uses YouTube TV's device code flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yt-dlp &lt;span class="nt"&gt;--username&lt;/span&gt; oauth2 &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; https://youtu.be/...
&lt;span class="c"&gt;# Go to google.com/device and enter code: XXXX-XXXX&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Result:&lt;/strong&gt; &lt;code&gt;Login with OAuth is no longer supported&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; YouTube deprecated this authentication method. It worked for a few months, then Google killed it. The &lt;a href="https://github.com/yt-dlp/yt-dlp/wiki/FAQ" rel="noopener noreferrer"&gt;yt-dlp wiki&lt;/a&gt; now says: "Use --cookies instead."&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #5: Per-User OAuth Bearer Token
&lt;/h2&gt;

&lt;p&gt;My app already had &lt;a href="https://developers.google.com/identity/protocols/oauth2" rel="noopener noreferrer"&gt;Google OAuth&lt;/a&gt; for YouTube uploads. Users sign in, I get their &lt;code&gt;access_token&lt;/code&gt;. Why not pass it to yt-dlp?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--add-header&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization:Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&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;Result:&lt;/strong&gt; &lt;code&gt;Request had insufficient authentication scopes&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; yt-dlp uses YouTube's &lt;strong&gt;InnerTube API&lt;/strong&gt; internally, not the &lt;a href="https://developers.google.com/youtube/v3" rel="noopener noreferrer"&gt;YouTube Data API v3&lt;/a&gt;. They're completely different authentication systems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Authentication&lt;/th&gt;
&lt;th&gt;Used by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YouTube Data API v3&lt;/td&gt;
&lt;td&gt;OAuth2 Bearer token&lt;/td&gt;
&lt;td&gt;App uploads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube InnerTube API&lt;/td&gt;
&lt;td&gt;Session cookies&lt;/td&gt;
&lt;td&gt;yt-dlp downloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A Data API token simply can't authenticate InnerTube requests.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #6: Firefox 🏆
&lt;/h2&gt;

&lt;p&gt;After 5 failures with Chrome, I tried Firefox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Firefox doesn't encrypt cookies.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Firefox stores cookies in a plain &lt;a href="https://www.sqlite.org/" rel="noopener noreferrer"&gt;SQLite&lt;/a&gt; database at &lt;code&gt;%APPDATA%/Mozilla/Firefox/Profiles/*/cookies.sqlite&lt;/code&gt;. No DPAPI. No AES-GCM. No app-bound encryption. Just SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;

&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;APPDATA&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Firefox&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Profiles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/*/cookies.sqlite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getsize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tmp.sqlite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Copy — Firefox locks the file
&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tmp.sqlite&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT host, name, value, path, expiry, isSecure &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FROM moz_cookies WHERE host LIKE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%youtube%&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; OR host LIKE &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;%google%&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;✅ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; cookies extracted&lt;/span&gt;&lt;span class="sh"&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;Result:&lt;/strong&gt; &lt;code&gt;✅ 67 cookies saved&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;10 lines of Python. No admin rights. No encryption libraries. No fighting the browser.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scoreboard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Blocked by&lt;/th&gt;
&lt;th&gt;Time&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;DPAPI + AES-GCM&lt;/td&gt;
&lt;td&gt;Chrome 127+ app-bound encryption&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/thewh1teazt3c/rookie" rel="noopener noreferrer"&gt;rookiepy&lt;/a&gt; (Rust lib)&lt;/td&gt;
&lt;td&gt;Same encryption, even as admin&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Chrome DevTools Protocol&lt;/td&gt;
&lt;td&gt;Port 9222 wouldn't bind&lt;/td&gt;
&lt;td&gt;40 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt; OAuth2&lt;/td&gt;
&lt;td&gt;Deprecated by YouTube&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Per-User Bearer token&lt;/td&gt;
&lt;td&gt;Wrong API (InnerTube ≠ Data API)&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Firefox SQLite&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Nothing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5 min&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Hypothesis vs Reality
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I expected&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cookie extraction is a solved problem&lt;/td&gt;
&lt;td&gt;Chrome made it unsolvable in July 2024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Admin rights bypass any protection&lt;/td&gt;
&lt;td&gt;App-bound encryption ignores admin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAuth tokens work across Google APIs&lt;/td&gt;
&lt;td&gt;InnerTube ≠ Data API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yt-dlp has built-in auth&lt;/td&gt;
&lt;td&gt;YouTube deprecated it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome extensions export cookies safely&lt;/td&gt;
&lt;td&gt;The most popular one was malware&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;It should take 10 minutes&lt;/td&gt;
&lt;td&gt;It took 3 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Why Chrome Does This
&lt;/h2&gt;

&lt;p&gt;Chrome's &lt;a href="https://security.googleblog.com/2024/07/improving-security-of-chrome-cookies-on.html" rel="noopener noreferrer"&gt;app-bound encryption&lt;/a&gt; exists for a good reason: protecting users from cookie-stealing malware. Before Chrome 127, any program running as the same Windows user could read all Chrome cookies — login sessions, banking tokens, everything.&lt;/p&gt;

&lt;p&gt;Google's fix: bind the decryption key to the Chrome binary. Only Chrome's own process can decrypt cookies. This blocks Raccoon, RedLine, Vidar, and all similar stealers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The collateral damage:&lt;/strong&gt; legitimate tools like yt-dlp, password managers, and cookie exporters also break.&lt;/p&gt;

&lt;p&gt;Firefox took a different approach: cookies are plain SQLite, and security relies on &lt;strong&gt;OS-level protections&lt;/strong&gt; — file permissions and user isolation. Neither approach is objectively better. Chrome prioritizes defense against same-user malware. Firefox prioritizes interoperability.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Deployment
&lt;/h2&gt;

&lt;p&gt;After extracting cookies, deployment was straightforward:&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;# Extract on local machine (Windows)&lt;/span&gt;
python extract_cookies.py
&lt;span class="c"&gt;# → cookies.txt (67 YouTube/Google cookies, Netscape format)&lt;/span&gt;

&lt;span class="c"&gt;# Upload to server&lt;/span&gt;
scp cookies.txt user@server:/opt/app/backend/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;yt-dlp uses them automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;COOKIES_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cookies.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yt-dlp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bestvideo+bestaudio&lt;/span&gt;&lt;span class="sh"&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;if&lt;/span&gt; &lt;span class="n"&gt;COOKIES_FILE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--cookies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COOKIES_FILE&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;Trade-off:&lt;/strong&gt; Cookies expire in ~2 weeks. When they do: open Firefox, visit YouTube, close Firefox, re-run the script. 60 seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with Firefox.&lt;/strong&gt; Chrome's encryption makes external cookie extraction impossible since July 2024.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Never install cookie-export extensions.&lt;/strong&gt; The popular "Get cookies.txt" was literal malware. Read the SQLite database directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Check the timeline.&lt;/strong&gt; Any cookie extraction tutorial from before Chrome 127 (mid-2024) won't work on modern Chrome.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;InnerTube ≠ Data API.&lt;/strong&gt; If you're building around yt-dlp, Google OAuth tokens from your app won't help — yt-dlp needs browser cookies, not API tokens.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Don't fight the browser.&lt;/strong&gt; When a browser actively resists external access, use a browser that doesn't.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Where It Stands Now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;Cookie extraction (Firefox)&lt;/td&gt;
&lt;td&gt;✅ Working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;yt-dlp downloads with cookies&lt;/td&gt;
&lt;td&gt;✅ Working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server deployment&lt;/td&gt;
&lt;td&gt;✅ Deployed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube upload after merge&lt;/td&gt;
&lt;td&gt;✅ OAuth2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cookie auto-refresh&lt;/td&gt;
&lt;td&gt;❌ Manual (every ~2 weeks)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The extraction script and the full project are open source: &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/maximosovsky/merge-video&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is part of a series about building &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt; — a self-hosted video merging service. Previous: &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project-oon"&gt;I Tried to Merge 52 Video Files Automatically&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>chrome</category>
      <category>security</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>7 Failed Attempts to Create One Cloud Server on Alibaba Cloud</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Sun, 01 Mar 2026 16:54:00 +0000</pubDate>
      <link>https://forem.com/osovsky/7-failed-attempts-to-create-one-cloud-server-on-alibaba-cloud-24gi</link>
      <guid>https://forem.com/osovsky/7-failed-attempts-to-create-one-cloud-server-on-alibaba-cloud-24gi</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 3. &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project"&gt;Part 1&lt;/a&gt; covered 3 bugs in the video merging engine. &lt;a href="https://dev.to/osovsky/3-deployment-fails-that-made-me-quit-oracle-cloud-forever-13gk"&gt;Part 2&lt;/a&gt; was about Oracle Cloud's "Always Free" servers that don't exist. This one is about what happened when I tried Alibaba Cloud instead.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Alibaba Cloud?
&lt;/h2&gt;

&lt;p&gt;I run long strategy sessions — 6 to 8 hours each — where we plan product roadmaps, discuss architecture, and make decisions on camera. Each session produces 30 to 50 short video clips that need to be merged into one continuous video.&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%2F7utx2n92h22z27knzdam.jpg" 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%2F7utx2n92h22z27knzdam.jpg" alt="Maxim Osovsky during a strategy session" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I offered &lt;strong&gt;$2,000&lt;/strong&gt; to a developer to build a service that merges videos and uploads them to YouTube. They turned it down — didn't see the problem it solved. Multiple developers refused. So I built &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt; myself — with AI as my pair programmer, in 3 days, for $0 in development costs. The code works. Now I needed to put it on a server.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/osovsky/3-deployment-fails-that-made-me-quit-oracle-cloud-forever-13gk"&gt;Part 2&lt;/a&gt; I deployed the &lt;a href="https://t.me/MergeVideoBot" rel="noopener noreferrer"&gt;Telegram bot&lt;/a&gt; to &lt;a href="https://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt; — 256 MB RAM, just enough for polling. But the backend needs &lt;a href="https://ffmpeg.org/" rel="noopener noreferrer"&gt;ffmpeg&lt;/a&gt;, &lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt;, and enough RAM to merge 50+ video files. It needs a real VPS.&lt;/p&gt;

&lt;p&gt;Oracle Cloud? &lt;a href="https://dev.to/osovsky/3-deployment-fails-that-made-me-quit-oracle-cloud-forever-13gk"&gt;Dead to me&lt;/a&gt;. AWS/GCP? No free tier for what I need. Hetzner? Great, but I already had Alibaba Cloud API keys — left over from &lt;a href="https://osovsky.com/wallplan" rel="noopener noreferrer"&gt;WallPlan&lt;/a&gt;, my calendar generator that I once deployed to Alibaba Cloud OSS through &lt;a href="https://maximosovsky.github.io/deploy-bridge/" rel="noopener noreferrer"&gt;DeployBridge&lt;/a&gt;. Those keys were still active. An ECS instance with 2 vCPU and a few GB of RAM — how hard could it be?&lt;/p&gt;

&lt;p&gt;Seven attempts hard.&lt;/p&gt;

&lt;h3&gt;
  
  
  No Console. Only Terminal.
&lt;/h3&gt;

&lt;p&gt;One important detail: &lt;strong&gt;I never opened the Alibaba Cloud web console.&lt;/strong&gt; Not once. The entire process — VPC creation, Security Groups, image search, instance provisioning, IP allocation — was done through the &lt;a href="https://www.npmjs.com/package/@alicloud/ecs20140526" rel="noopener noreferrer"&gt;Alibaba Cloud ECS SDK&lt;/a&gt; via Node.js scripts running in my terminal.&lt;/p&gt;

&lt;p&gt;Why? Because I script my deployments. The same API keys from the &lt;a href="https://github.com/maximosovsky/wallplan" rel="noopener noreferrer"&gt;WallPlan&lt;/a&gt; project that went through &lt;a href="https://github.com/maximosovsky/deploy-bridge" rel="noopener noreferrer"&gt;DeployBridge&lt;/a&gt; — a deployment tool I built for moving sites from Vercel to cheaper hosting — were reused here with zero additional setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #1: Frankfurt, Wrong Image
&lt;/h2&gt;

&lt;p&gt;I picked Frankfurt (&lt;code&gt;eu-central-1&lt;/code&gt;) — closest to me. Wrote a Node.js script using the &lt;a href="https://www.npmjs.com/package/@alicloud/ecs20140526" rel="noopener noreferrer"&gt;Alibaba Cloud ECS SDK&lt;/a&gt; to automate everything: VPC, VSwitch, Security Group, instance.&lt;/p&gt;

&lt;p&gt;The VPC and VSwitch created fine. The Security Group created fine. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Finding Ubuntu 22.04 image...
Image: ubuntu_22_04_arm64_20G_alibase_20260119.vhd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait — &lt;strong&gt;arm64&lt;/strong&gt;? I asked for &lt;code&gt;ecs.c6.large&lt;/code&gt;, which is x86. The SDK returned the ARM image first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: InvalidInstanceType.NotSupported
The specified instanceType is not supported by the image architecture.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson: Alibaba's image search doesn't filter by architecture unless you explicitly ask.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #2: Frankfurt, No Ubuntu at All
&lt;/h2&gt;

&lt;p&gt;I added &lt;code&gt;architecture: 'x86_64'&lt;/code&gt; to the image search. The result?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: No Ubuntu image found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Frankfurt has &lt;strong&gt;zero&lt;/strong&gt; x86 Ubuntu images. Only ARM. The entire region. I listed all available images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;debian_12_13_x64_20G_alibase_20260120.vhd: Debian 12.13 64位
centos_stream_9_x64_20G_alibase_20260120.vhd: CentOS Stream 9
aliyun_4_x64_20G_alibase_20260120.vhd: Alibaba Cloud Linux 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No Ubuntu. Fine — Debian 12 is basically Ubuntu without Canonical. I switched to Debian.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #3: Frankfurt, Zone Not for Sale
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3. Creating ECS (ecs.c6.large, 2vCPU, 4GB)...
Error: Zone.NotOnSale
The specified zone is not available for purchase.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The entire Frankfurt zone doesn't sell &lt;code&gt;ecs.c6.large&lt;/code&gt;.&lt;/strong&gt; Not out of stock — not &lt;em&gt;available for purchase&lt;/em&gt;. The instance type doesn't exist there for PayAsYouGo.&lt;/p&gt;

&lt;p&gt;That's when I abandoned Frankfurt entirely and switched to Singapore (&lt;code&gt;ap-southeast-1&lt;/code&gt;).&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #4: Singapore, Wrong Disk
&lt;/h2&gt;

&lt;p&gt;Singapore accepted my VPC, VSwitch, Security Group. Found Debian 12. Instance type available. Progress!&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3. Creating ECS (ecs.c6.large, 2 vCPU, 4 GB)...
Error: InvalidDiskCategory.NotSupported
The specified disk category is not supported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cloud_efficiency&lt;/code&gt; — not supported. Changed to &lt;code&gt;cloud_essd&lt;/code&gt;. Still not supported. Changed to &lt;code&gt;cloud_ssd&lt;/code&gt;. Still not supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three disk types. All rejected. For the same instance type.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #5: Singapore, Instance Doesn't Exist
&lt;/h2&gt;

&lt;p&gt;I queried &lt;code&gt;DescribeAvailableResource&lt;/code&gt; (should have done this from the start). The shocking result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ecs.c6.large — not found in any Singapore zone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ecs.c6.large&lt;/code&gt; &lt;strong&gt;doesn't exist in Singapore.&lt;/strong&gt; Not out of stock — the instance type isn't offered. Only &lt;code&gt;ecs.c6t&lt;/code&gt; (trusted) and &lt;code&gt;ecs.c6r&lt;/code&gt; (ARM) variants.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #6: Singapore, ARM Again
&lt;/h2&gt;

&lt;p&gt;I tried &lt;code&gt;ecs.c6r.large&lt;/code&gt; — thinking "r" meant "regular."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: InvalidInstanceType.NotSupported
The specified instanceType is not supported by the image architecture.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"r" means &lt;strong&gt;ARM&lt;/strong&gt;. My Debian image was x86. Architecture mismatch. Again.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt #7: Singapore, KMS Required
&lt;/h2&gt;

&lt;p&gt;I switched to &lt;code&gt;ecs.c6t.large&lt;/code&gt; — "t" for "trusted." Should work with x86, right?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: InvalidParameter.KmsNotEnabled
Failed to perform this operation because KMS is not activated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trusted instances require &lt;a href="https://www.alibabacloud.com/product/key-management-service" rel="noopener noreferrer"&gt;KMS&lt;/a&gt; (Key Management Service) — a separate service that needs activation. I just wanted a Linux box.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scorecard after 7 attempts
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Instance&lt;/th&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Disk&lt;/th&gt;
&lt;th&gt;Error&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;Frankfurt&lt;/td&gt;
&lt;td&gt;ecs.c6.large&lt;/td&gt;
&lt;td&gt;Ubuntu ARM&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Wrong architecture&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Frankfurt&lt;/td&gt;
&lt;td&gt;ecs.c6.large&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;No Ubuntu x86 exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Frankfurt&lt;/td&gt;
&lt;td&gt;ecs.c6.large&lt;/td&gt;
&lt;td&gt;Debian x86&lt;/td&gt;
&lt;td&gt;cloud_efficiency&lt;/td&gt;
&lt;td&gt;Zone not for sale&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;ecs.c6.large&lt;/td&gt;
&lt;td&gt;Debian x86&lt;/td&gt;
&lt;td&gt;cloud_*&lt;/td&gt;
&lt;td&gt;Disk not supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;ecs.c6.large&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Instance type doesn't exist&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;ecs.c6r.large&lt;/td&gt;
&lt;td&gt;Debian x86&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;ARM instance, x86 image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Singapore&lt;/td&gt;
&lt;td&gt;ecs.c6t.large&lt;/td&gt;
&lt;td&gt;Debian x86&lt;/td&gt;
&lt;td&gt;cloud_essd&lt;/td&gt;
&lt;td&gt;KMS not activated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;7 API calls. 7 different errors. 0 servers.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Fix: Query First, Create Second
&lt;/h2&gt;

&lt;p&gt;I finally wrote what I should have written from the start — a &lt;strong&gt;probe script&lt;/strong&gt; that checks everything before creating anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Find instance types with stock&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ecs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;describeAvailableResource&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;regionId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ap-southeast-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;destinationResource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SystemDisk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instanceChargeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PostPaid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;instanceType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Find zones with stock&lt;/span&gt;
&lt;span class="c1"&gt;// 3. Find compatible disk categories&lt;/span&gt;
&lt;span class="c1"&gt;// 4. Find matching VSwitch or create one&lt;/span&gt;
&lt;span class="c1"&gt;// 5. THEN create the instance&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script tested 4 candidate instance types across all zones. In seconds, it found what worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FOUND: ecs.t6-c1m4.large in ap-southeast-1c with cloud_efficiency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ecs.t6-c1m4.large&lt;/code&gt; — a burstable type I'd never heard of. Zone C — not the default zone the SDK picked. &lt;code&gt;cloud_efficiency&lt;/code&gt; — the same disk type that was &lt;em&gt;rejected&lt;/em&gt; for &lt;code&gt;ecs.c6.large&lt;/code&gt; two attempts earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One probe. One working combination. Instance created in 30 seconds.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Instance: i-xxxxxxxxxxxxxxxxxxxx
IP:       203.0.113.42
SSH:      ssh root@203.0.113.42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Deploying the Backend
&lt;/h2&gt;

&lt;p&gt;With the server finally alive, I piped a setup script via SSH. The script creates a dedicated &lt;code&gt;deploy&lt;/code&gt; user — the backend runs under that account, not root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Convert CRLF → LF (Windows → Linux)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IO.File&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;ReadAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"deploy/remote-setup.sh"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$lfContent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-replace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;`r`n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;`n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;IO.File&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;WriteAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$temp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$lfContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-Content&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$temp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Raw&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;203.0.113.42&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash -s"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script installed everything in 3 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; python3 python3-venv ffmpeg nginx git
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv /opt/merge-video/venv
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; backend/requirements.txt
systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;merge-video
systemctl start merge-video
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;● merge-video.service - Merge Video Backend (FastAPI)
     Active: active (running)
     Memory: 106.1M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;106 MB. Running. After 7 failed attempts and one working probe.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I assumed&lt;/th&gt;
&lt;th&gt;What's actually true&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ecs.c6.large&lt;/code&gt; exists everywhere&lt;/td&gt;
&lt;td&gt;It exists in &lt;em&gt;some&lt;/em&gt; regions, &lt;em&gt;some&lt;/em&gt; zones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ubuntu is always available&lt;/td&gt;
&lt;td&gt;Frankfurt has &lt;strong&gt;zero&lt;/strong&gt; x86 Ubuntu images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk types are universal&lt;/td&gt;
&lt;td&gt;Each instance type supports different disks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"c6r" means regular&lt;/td&gt;
&lt;td&gt;It means ARM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"c6t" means standard&lt;/td&gt;
&lt;td&gt;It means trusted (requires KMS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You can guess and retry&lt;/td&gt;
&lt;td&gt;You &lt;strong&gt;must&lt;/strong&gt; query &lt;code&gt;DescribeAvailableResource&lt;/code&gt; first&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The real takeaway
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Alibaba Cloud's API is a compatibility matrix, not a catalog.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Unlike AWS where &lt;code&gt;t3.micro&lt;/code&gt; works everywhere with any disk, Alibaba Cloud has &lt;strong&gt;per-zone, per-type, per-disk&lt;/strong&gt; compatibility rules that aren't surfaced in the console or documentation. The only way to know what works is to call &lt;code&gt;DescribeAvailableResource&lt;/code&gt; and cross-reference instance types, zones, disk categories, and image architectures.&lt;/p&gt;

&lt;p&gt;One API call would have saved me 7 failed attempts.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where It Stands Now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;Web app — merge &amp;amp; download&lt;/td&gt;
&lt;td&gt;✅ Working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram bot&lt;/td&gt;
&lt;td&gt;✅ &lt;a href="https://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt; (256 MB, stable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend (FastAPI + ffmpeg)&lt;/td&gt;
&lt;td&gt;✅ Alibaba ECS Singapore (2 vCPU, 8 GB RAM)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email notifications&lt;/td&gt;
&lt;td&gt;✅ &lt;a href="https://developers.google.com/gmail/api" rel="noopener noreferrer"&gt;Gmail API&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube upload&lt;/td&gt;
&lt;td&gt;✅ OAuth2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL + domain&lt;/td&gt;
&lt;td&gt;⏳ Next: &lt;code&gt;merge-video.osovsky.com&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube auth via bot&lt;/td&gt;
&lt;td&gt;⏳ After backend goes public&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The project is open source: &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/maximosovsky/merge-video&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or send YouTube links to the Telegram bot: &lt;a href="https://t.me/MergeVideoBot" rel="noopener noreferrer"&gt;@MergeVideoBot&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;This is Part 3. &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project"&gt;Part 1&lt;/a&gt; covered 3 bugs in the video merging engine. &lt;a href="https://dev.to/osovsky/3-deployment-fails-that-made-me-quit-oracle-cloud-forever-13gk"&gt;Part 2&lt;/a&gt; was about Oracle Cloud. Next: connecting DNS, SSL, and YouTube auth through the bot.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ever had a cloud provider reject 7 different configurations in a row? I'd love to hear your worst. Comments are open.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>deployment</category>
      <category>buildinpublic</category>
      <category>cloud</category>
    </item>
    <item>
      <title>3 Deployment Fails That Made Me Quit Oracle Cloud Forever</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Sat, 28 Feb 2026 16:54:00 +0000</pubDate>
      <link>https://forem.com/osovsky/3-deployment-fails-that-made-me-quit-oracle-cloud-forever-5fnn</link>
      <guid>https://forem.com/osovsky/3-deployment-fails-that-made-me-quit-oracle-cloud-forever-5fnn</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 2. &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project"&gt;Part 1&lt;/a&gt; covered 3 bugs I hit while building the video merging engine. This one is about what happened when I tried to put it online.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Plan That Looked Too Good
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project"&gt;Part 1&lt;/a&gt; I built &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt; — a service that merges dozens of video files using &lt;a href="https://ffmpeg.org/" rel="noopener noreferrer"&gt;ffmpeg&lt;/a&gt; and uploads the result to YouTube. I film long strategy sessions — 6 to 8 hours each — and end up with 30–50 clips per session that need to be merged into one video. It worked on my machine. Now I needed to deploy the &lt;a href="https://core.telegram.org/bots/api" rel="noopener noreferrer"&gt;Telegram bot&lt;/a&gt; so it runs 24/7 without my laptop being open.&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%2Fraw.githubusercontent.com%2Fmaximosovsky%2Fmerge-video%2Fmaster%2Fassets%2Fmaxim-osovsky-strategy-session.JPG" 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%2Fraw.githubusercontent.com%2Fmaximosovsky%2Fmerge-video%2Fmaster%2Fassets%2Fmaxim-osovsky-strategy-session.JPG" alt="Maxim Osovsky during a strategy session" width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Free&lt;/strong&gt; — at the prototype stage, free tier makes the most sense. Cloud providers offer generous free tiers specifically for this use case — validating ideas before committing budget. Once there's real traffic and real users, paying for infrastructure is a no-brainer. But spending money before product-market fit is just burning runway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistent&lt;/strong&gt; — a long-polling bot needs to stay alive, not sleep after 15 minutes of inactivity like &lt;a href="https://render.com/" rel="noopener noreferrer"&gt;Render&lt;/a&gt;'s free tier&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://www.oracle.com/cloud/free/" rel="noopener noreferrer"&gt;Oracle Cloud Free Tier&lt;/a&gt; looked perfect on paper: &lt;strong&gt;4 ARM CPUs, 24 GB RAM, 200 GB disk&lt;/strong&gt; — free forever. Not a trial. Not 12 months. &lt;em&gt;Forever.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I signed up, picked a datacenter, and started creating a VM.&lt;/p&gt;

&lt;p&gt;What followed was the most frustrating 2 hours I've had with any cloud provider.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fail #1: The Server That Doesn't Exist
&lt;/h2&gt;

&lt;p&gt;I selected the best free shape — &lt;strong&gt;VM.Standard.A1.Flex&lt;/strong&gt; (ARM, 4 CPUs, 24 GB RAM). Configured everything. Hit Create.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API Error:
Out of capacity for shape VM.Standard.A1.Flex in availability domain AD-1.
Create the instance in a different availability domain or try again later.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No ARM instances available. The datacenter was fully packed. All of them. Taken.&lt;/p&gt;

&lt;p&gt;Fine — fallback plan. I switched to the weaker x86 shape: &lt;strong&gt;VM.Standard.E2.1.Micro&lt;/strong&gt; (1 CPU, 1 GB RAM). Not great for video processing, but enough for a Telegram bot.&lt;/p&gt;

&lt;p&gt;Same error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Out of capacity for shape VM.Standard.E2.1.Micro in availability domain AD-1.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Both free shapes — zero availability.&lt;/strong&gt; The "Always Free" tier had no servers to give me. The marketing page promises 24 GB of RAM. The reality is a queue with no ETA.&lt;/p&gt;

&lt;p&gt;Online advice says: &lt;em&gt;"Try early morning (5–7 UTC), capacity comes in waves."&lt;/em&gt; Some people write scripts that poll the Oracle API every 5 minutes, for &lt;strong&gt;days&lt;/strong&gt;, waiting for a slot to open.&lt;/p&gt;

&lt;p&gt;I didn't want to write a bot to get a server to run my bot.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fail #2: The Checkbox That Won't Check
&lt;/h2&gt;

&lt;p&gt;Before the capacity error killed my attempt entirely, I spent &lt;strong&gt;40 minutes&lt;/strong&gt; fighting Oracle's VM creation UI.&lt;/p&gt;

&lt;p&gt;The problem: the &lt;strong&gt;"Automatically assign public IPv4 address"&lt;/strong&gt; checkbox was grayed out. Without a public IP, the server is invisible to the internet — no SSH access, no Telegram connection, nothing.&lt;/p&gt;

&lt;p&gt;I stared at it. Refreshed the page. Tried a different browser. The checkbox wouldn't budge.&lt;/p&gt;

&lt;p&gt;The root cause? When you create a new subnet &lt;em&gt;inline&lt;/em&gt; during VM creation, Oracle's UI doesn't recognize it as "public" — even though you explicitly selected &lt;strong&gt;"Create new public subnet."&lt;/strong&gt; The UI disagrees with itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The workaround I had to discover myself
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Cancel VM creation entirely&lt;/li&gt;
&lt;li&gt;Navigate to &lt;strong&gt;Networking → Virtual Cloud Networks → Start VCN Wizard&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Run the wizard to create a VCN with internet connectivity&lt;/li&gt;
&lt;li&gt;Go &lt;em&gt;back&lt;/em&gt; to Compute → Create Instance&lt;/li&gt;
&lt;li&gt;Select the existing VCN → existing public subnet&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Now&lt;/em&gt; the checkbox works&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three separate pages. A wizard inside a wizard. To enable a checkbox that should have worked from the start.&lt;/p&gt;

&lt;p&gt;I spent 40 minutes on this &lt;em&gt;before&lt;/em&gt; discovering I couldn't get a server anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Fail #3: One Card, One Life
&lt;/h2&gt;

&lt;p&gt;After getting the capacity error on &lt;em&gt;both&lt;/em&gt; shapes, I thought: what if the problem is this specific datacenter? Some regions have more available servers than others.&lt;/p&gt;

&lt;p&gt;I couldn't change regions on my existing account — Oracle locks your home region at signup. So I went all in: registered a &lt;strong&gt;second account&lt;/strong&gt; with a different Gmail address (not even an alias — Oracle didn't accept &lt;code&gt;+&lt;/code&gt; aliases). Used a Regus coworking address in Tilburg, Netherlands as my billing address. Used my second phone number for verification.&lt;/p&gt;

&lt;p&gt;Different email. Different address. Different phone. Made it through the entire signup flow. Got to the payment verification step. Entered my card.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: You already have an account with a different email address.
Oracle allows one promotion per person.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the moment I was done.&lt;/p&gt;

&lt;p&gt;Oracle links your identity to your &lt;strong&gt;card number&lt;/strong&gt;. One card = one account = one datacenter = zero servers. Different email doesn't matter. Different address doesn't matter. Different phone doesn't matter. You get one shot, and if your datacenter is full — tough luck.&lt;/p&gt;

&lt;p&gt;No retry. No workaround. No path forward.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scorecard after 2 hours with Oracle Cloud
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I tried&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ARM shape (A1.Flex, 4 CPU, 24 GB)&lt;/td&gt;
&lt;td&gt;❌ Out of capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;x86 shape (E2.1.Micro, 1 CPU, 1 GB)&lt;/td&gt;
&lt;td&gt;❌ Out of capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public IPv4 checkbox&lt;/td&gt;
&lt;td&gt;❌ Grayed out (UI bug)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual VCN + public subnet&lt;/td&gt;
&lt;td&gt;✅ Fixed the checkbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VM creation after VCN fix&lt;/td&gt;
&lt;td&gt;❌ Still out of capacity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second account (new email, new address, new phone)&lt;/td&gt;
&lt;td&gt;❌ Blocked by card&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 hours&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total servers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total RAM obtained&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 bytes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Pivot: Fly.io in 10 Minutes
&lt;/h2&gt;

&lt;p&gt;I closed Oracle's console and opened &lt;a href="https://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;No VMs. No VCN wizards. No availability domains. Just a &lt;code&gt;Dockerfile&lt;/code&gt; and a CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;fly auth login
fly launch &lt;span class="nt"&gt;--no-deploy&lt;/span&gt;
fly secrets &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;BOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
fly deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;10 minutes.&lt;/strong&gt; DNS verified. App live at &lt;code&gt;merge-video-bot.fly.dev&lt;/code&gt;. &lt;a href="https://docs.aiogram.dev/" rel="noopener noreferrer"&gt;aiogram&lt;/a&gt; connected to &lt;a href="https://core.telegram.org/bots/api" rel="noopener noreferrer"&gt;Telegram Bot API&lt;/a&gt;. Bot started polling.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Oracle Cloud&lt;/th&gt;
&lt;th&gt;Fly.io&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Time to deploy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2 hours&lt;/td&gt;
&lt;td&gt;10 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Result&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0 servers&lt;/td&gt;
&lt;td&gt;1 running bot&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI complexity&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6 pages, 2 wizards&lt;/td&gt;
&lt;td&gt;4 CLI commands&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Account tricks needed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Second email, Regus address, second phone&lt;/td&gt;
&lt;td&gt;GitHub login&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything worked.&lt;/p&gt;

&lt;p&gt;And it's been running stable since.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I expected&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Oracle Free Tier = free server&lt;/td&gt;
&lt;td&gt;Free tier with &lt;strong&gt;zero available servers&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Always Free" means always available&lt;/td&gt;
&lt;td&gt;Means always free &lt;em&gt;if you can get one&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Oracle Cloud UI is enterprise-grade&lt;/td&gt;
&lt;td&gt;3-page workaround for a single checkbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different email + address + phone = new account&lt;/td&gt;
&lt;td&gt;One &lt;strong&gt;card&lt;/strong&gt; = one account, forever&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy takes 30 minutes&lt;/td&gt;
&lt;td&gt;Oracle: 2 hours → nothing. Fly.io: 10 minutes → working&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Where It Stands Now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;Web app — merge &amp;amp; download&lt;/td&gt;
&lt;td&gt;✅ Working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram bot&lt;/td&gt;
&lt;td&gt;✅ Deployed on &lt;a href="https://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt; (256 MB, stable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email notifications&lt;/td&gt;
&lt;td&gt;✅ &lt;a href="https://developers.google.com/gmail/api" rel="noopener noreferrer"&gt;Gmail API&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube upload&lt;/td&gt;
&lt;td&gt;✅ OAuth2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stress test (52 files, 13 GB)&lt;/td&gt;
&lt;td&gt;✅ Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend deployment&lt;/td&gt;
&lt;td&gt;❌ Still localhost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube auth via bot&lt;/td&gt;
&lt;td&gt;❌ Needs deployed backend&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The project is open source: &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/maximosovsky/merge-video&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Or send YouTube links to the Telegram bot: &lt;a href="https://t.me/MergeVideoBot" rel="noopener noreferrer"&gt;@MergeVideoBot&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;This is part 2. Part 1 covered &lt;a href="https://dev.to/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project"&gt;3 bugs in the video merging engine&lt;/a&gt;. Next: deploying the backend and connecting YouTube auth through the bot.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ever been burned by a "free" cloud? Drop your horror story in the comments.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>deployment</category>
      <category>buildinpublic</category>
      <category>python</category>
    </item>
    <item>
      <title>I Tried to Merge 52 Video Files Automatically. Here Are 3 Bugs That Almost Killed the Project</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Fri, 27 Feb 2026 16:54:00 +0000</pubDate>
      <link>https://forem.com/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project-104i</link>
      <guid>https://forem.com/osovsky/i-tried-to-merge-52-video-files-automatically-here-are-3-bugs-that-almost-killed-the-project-104i</guid>
      <description>&lt;h2&gt;
  
  
  The $1,500 Problem
&lt;/h2&gt;

&lt;p&gt;I film long strategy sessions — 6 to 8 hours each. When you record for that long, things go wrong. The battery dies. The power goes out. And when it does, the entire file gets corrupted.&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%2Fraw.githubusercontent.com%2Fmaximosovsky%2Fmerge-video%2Fmaster%2Fassets%2Fmaxim-osovsky-strategy.jpg" 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%2Fraw.githubusercontent.com%2Fmaximosovsky%2Fmerge-video%2Fmaster%2Fassets%2Fmaxim-osovsky-strategy.jpg" alt="Maxim Osovsky filming a strategy session" width="800" height="375"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So I set my camera to record in short segments — about 20 minutes each. Safe, but now I have &lt;strong&gt;52 separate video files&lt;/strong&gt; after every session that I need to merge into one.&lt;/p&gt;

&lt;p&gt;I used to outsource this. Send files to an editor, wait, pay. Over the years I've spent at least &lt;strong&gt;$1,500–2,000&lt;/strong&gt; just on merging clips — not editing, not color grading, just &lt;em&gt;joining files together&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;In 2022 I decided to build my own service. I called it &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Merge Video&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Road Here: 3 Failed Attempts
&lt;/h2&gt;

&lt;p&gt;This isn't v1. I've been trying to solve this problem since 2022 — across three separate repositories:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Stack&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/Merge-video.online" rel="noopener noreferrer"&gt;v1 — Merge-video.online&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Node.js, Telegraf, youtube-dl, ffmpeg&lt;/td&gt;
&lt;td&gt;Worked on AWS EC2 but died with the server. Single 424-line file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/merge-video-landing" rel="noopener noreferrer"&gt;Landing page&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Umso no-code builder, GitHub Pages&lt;/td&gt;
&lt;td&gt;Static promo page&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/Merve-video.online-vol.2-" rel="noopener noreferrer"&gt;v2 — Microservices&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Python, aiogram, Flask, FastAPI&lt;/td&gt;
&lt;td&gt;Ambitious: 3 microservices, payments, Google Drive delivery. Pytube broke when YouTube changed their API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The original idea was &lt;strong&gt;fully cloud-based&lt;/strong&gt;: take videos from YouTube or Google Drive, merge them on the server, upload back to YouTube — without ever touching the local machine. It worked for small files, but my real problem was 52 local recordings sitting on a hard drive.&lt;/p&gt;

&lt;p&gt;Multiple developers refused to work on this project. They didn't see the problem it solved — "just use a video editor." One developer turned down a &lt;strong&gt;$2,000 offer&lt;/strong&gt; to automate the pipeline.&lt;/p&gt;

&lt;p&gt;The current version — &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;merge-video&lt;/a&gt; — consolidates all three repos into one and adds what was always missing: &lt;strong&gt;local file upload and merge&lt;/strong&gt;. I rebuilt the frontend and backend in &lt;strong&gt;3 days&lt;/strong&gt; with the help of &lt;a href="https://blog.google/technology/google-deepmind/gemini-ai-update-august-2024/" rel="noopener noreferrer"&gt;Antigravity&lt;/a&gt;, an AI coding assistant by Google DeepMind. What took months of failed outsourcing now took a weekend of focused work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;The idea is simple: &lt;strong&gt;send links or upload files, go to sleep, wake up to a merged video on YouTube and a link in your email.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No manual work. No editor. No waiting at the screen.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;How it works&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YouTube URLs&lt;/td&gt;
&lt;td&gt;Paste links → &lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt; downloads → &lt;a href="https://ffmpeg.org/" rel="noopener noreferrer"&gt;ffmpeg&lt;/a&gt; merges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local files&lt;/td&gt;
&lt;td&gt;Drag &amp;amp; drop up to 100 files → server merges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube upload&lt;/td&gt;
&lt;td&gt;Merged result uploads to your channel via OAuth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email notifications&lt;/td&gt;
&lt;td&gt;📧 Auth → Start → Done/Error — all sent via &lt;a href="https://developers.google.com/gmail/api" rel="noopener noreferrer"&gt;Gmail API&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram bot&lt;/td&gt;
&lt;td&gt;Send YouTube links to &lt;a href="https://t.me/MergeVideoBot" rel="noopener noreferrer"&gt;@MergeVideoBot&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 quality modes&lt;/td&gt;
&lt;td&gt;Compact (CRF 23) · High Quality (CRF 18) · Lossless (concat demuxer)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The "Email Bot" Concept
&lt;/h3&gt;

&lt;p&gt;I started with a &lt;a href="https://core.telegram.org/bots/api" rel="noopener noreferrer"&gt;Telegram bot&lt;/a&gt; — it was the quickest way to build an interface. But I realized: not everything should live inside Telegram.&lt;/p&gt;

&lt;p&gt;What I really wanted was an &lt;strong&gt;email bot&lt;/strong&gt;. Not a chatbot. The idea:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You submit files or links through the web app&lt;/li&gt;
&lt;li&gt;You close the browser and go to sleep&lt;/li&gt;
&lt;li&gt;The server does everything in the background&lt;/li&gt;
&lt;li&gt;You wake up to an email: &lt;code&gt;🎬 Your merged video is ready! ▶ View on YouTube&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The merge runs on the server regardless of whether the browser is open. Gmail API sends you status updates at every step — authorization, job start, completion, and errors. All from your own Gmail, to your own Gmail. No SMTP servers, no third-party email services.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser / Telegram Bot
        ↓
   FastAPI Backend
        ↓
  ┌─────────────┐
  │  Job Queue   │ ← async single-worker
  │  (in-memory) │
  └──────┬──────┘
         ↓
  yt-dlp → ffmpeg → YouTube API
         ↓
  Gmail API → email notification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stack:&lt;/strong&gt; &lt;a href="https://www.python.org/" rel="noopener noreferrer"&gt;Python&lt;/a&gt; 3.12, &lt;a href="https://fastapi.tiangolo.com/" rel="noopener noreferrer"&gt;FastAPI&lt;/a&gt;, &lt;a href="https://ffmpeg.org/" rel="noopener noreferrer"&gt;ffmpeg&lt;/a&gt;, &lt;a href="https://github.com/yt-dlp/yt-dlp" rel="noopener noreferrer"&gt;yt-dlp&lt;/a&gt;, &lt;a href="https://docs.aiogram.dev/" rel="noopener noreferrer"&gt;aiogram&lt;/a&gt; 3, Google OAuth2&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stress Test: 52 Files, 13 GB
&lt;/h2&gt;

&lt;p&gt;Everything worked fine on small tests — 2 files, 40 MB each, merged in a minute. So I ran the real thing: &lt;strong&gt;52 video files, 13 GB total&lt;/strong&gt;.&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%2Fv8lst0pjqjlj29m08rwp.jpg" 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%2Fv8lst0pjqjlj29m08rwp.jpg" alt="52 video files loaded into Merge Video by Maxim Osovsky" width="800" height="439"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things broke. Every one of them taught me something.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bug #1: ffmpeg Choked on 52 Mixed-Format Files
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;I fed ffmpeg a single command with 52 inputs using &lt;code&gt;filter_complex&lt;/code&gt;. Some files were 4K (3840×2160), others were 1080p, and some had no audio track. ffmpeg crashed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input link in0:v0 parameters (size 1920x1080, SAR 1:1) do not match
the corresponding output link parameters (3840x2160, SAR 1:1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The concat filter requires &lt;strong&gt;all inputs to have identical parameters&lt;/strong&gt; — same resolution, same codec, same audio format. With 52 random files, that's never the case.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I tried first
&lt;/h3&gt;

&lt;p&gt;Added &lt;code&gt;scale&lt;/code&gt; and &lt;code&gt;pad&lt;/code&gt; filters to normalize everything to 1920×1080 inside the same massive &lt;code&gt;filter_complex&lt;/code&gt;. Still crashed — the filter graph with 52 inputs was too complex and fragile.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually worked: Two-Pass Merge
&lt;/h3&gt;

&lt;p&gt;I completely changed the approach:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pass 1 — Normalize each file independently:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  📦 Normalizing &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Scale to target resolution with letterbox
&lt;/span&gt;    &lt;span class="c1"&gt;# Add silent audio if missing (detected via ffprobe)
&lt;/span&gt;    &lt;span class="c1"&gt;# Re-encode to uniform h264/aac format
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pass 2 — Concat demuxer (no re-encoding):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Write file list
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;nf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;normalized_files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;list_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nf&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Merge without re-encoding — instant
&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ffmpeg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;concat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-safe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-i&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;list_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;copy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gave me a bonus I didn't expect: &lt;strong&gt;visible progress&lt;/strong&gt;. Instead of a silent 30-minute ffmpeg run, I could see &lt;code&gt;📦 Normalizing 1/52... 2/52... 3/52...&lt;/code&gt; in the terminal. Debugging became trivial — if file #37 fails, you know exactly which one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lesson
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;When a single-step pipeline breaks under scale, don't fix the step — &lt;strong&gt;split it into stages.&lt;/strong&gt; Each stage is simpler, debuggable, and independently testable.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Bug #2: 13 GB of stderr Crashed Python
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;The merge ran for a few minutes, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Exception in thread Thread-2 (_readerthread):
MemoryError
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This wasn't ffmpeg failing. It was &lt;strong&gt;Python's &lt;code&gt;subprocess.run&lt;/code&gt;&lt;/strong&gt; trying to read ffmpeg's stderr output into memory. When processing 13 GB of video, ffmpeg writes progress for every single frame to stderr — that's gigabytes of text output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it worked on small files
&lt;/h3&gt;

&lt;p&gt;With 2 files totaling 80 MB, ffmpeg's stderr output was maybe a few kilobytes. &lt;code&gt;subprocess.PIPE&lt;/code&gt; handled it fine. At 13 GB and thousands of frames? Python ran out of memory before ffmpeg even finished.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix
&lt;/h3&gt;

&lt;p&gt;Redirect stdout and stderr to &lt;strong&gt;temp files on disk&lt;/strong&gt; instead of memory pipes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sync_run&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NamedTemporaryFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;out_f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \
             &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NamedTemporaryFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;err_f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;out_f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;err_f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="c1"&gt;# On error, read only the last 4KB of stderr
&lt;/span&gt;            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;err_f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err_f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tell&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err_f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_sync_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The lesson
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;subprocess.PIPE&lt;/code&gt; is a time bomb for long-running processes. If you can't predict the output size, &lt;strong&gt;write to files.&lt;/strong&gt; This is standard in DevOps but easy to miss in application code.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Bug #3: &lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; Doesn't Work on Windows
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What happened
&lt;/h3&gt;

&lt;p&gt;The first time I tried to merge anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌ Error: NotImplementedError()
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; requires &lt;code&gt;ProactorEventLoop&lt;/code&gt; on Windows. But &lt;a href="https://www.uvicorn.org/" rel="noopener noreferrer"&gt;uvicorn&lt;/a&gt; (the ASGI server running FastAPI) sets its own event loop policy and overrides mine.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I tried first
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Tried setting the policy in main.py — uvicorn overwrites it
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;win32&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_event_loop_policy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WindowsProactorEventLoopPolicy&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Didn't work. Uvicorn ignores this and uses its own loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  What actually worked
&lt;/h3&gt;

&lt;p&gt;Gave up on &lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; entirely. Used synchronous &lt;code&gt;subprocess.run&lt;/code&gt; wrapped in &lt;code&gt;asyncio.to_thread&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_sync_run&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;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_sync_run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Less elegant than native async subprocess, but &lt;strong&gt;it works on every platform&lt;/strong&gt; without fighting the ASGI server.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lesson
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"Correct by documentation" ≠ "works in your stack." When two frameworks fight over the event loop, sometimes the pragmatic solution beats the elegant one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I expected&lt;/th&gt;
&lt;th&gt;What actually happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ffmpeg handles any number of inputs&lt;/td&gt;
&lt;td&gt;52 mixed-format files = crash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;subprocess.PIPE&lt;/code&gt; is fine for any process&lt;/td&gt;
&lt;td&gt;13 GB of stderr = MemoryError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;asyncio.create_subprocess_exec&lt;/code&gt; is cross-platform&lt;/td&gt;
&lt;td&gt;Windows + uvicorn = NotImplementedError&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small test = production-ready&lt;/td&gt;
&lt;td&gt;Small test hides 3 critical bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;Milestone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2022&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/maximosovsky/Merge-video.online" rel="noopener noreferrer"&gt;v1&lt;/a&gt; — Node.js Telegram bot on AWS EC2. Worked but fragile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/maximosovsky/Merve-video.online-vol.2-" rel="noopener noreferrer"&gt;v2&lt;/a&gt; — Python microservices, payments. External dependencies killed it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/maximosovsky/merge-video-landing" rel="noopener noreferrer"&gt;Landing page&lt;/a&gt; — Umso builder, Product Hunt links&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;Multiple developers decline the project. $2,000 offered and refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;Current version&lt;/a&gt; — rebuilt in 3 days with AI. FastAPI + yt-dlp + ffmpeg + Gmail + YouTube OAuth&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where It Stands Now
&lt;/h2&gt;

&lt;p&gt;This is a build-in-public project. Some things work, some don't yet:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&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;Web app — merge &amp;amp; download&lt;/td&gt;
&lt;td&gt;✅ Working&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram bot&lt;/td&gt;
&lt;td&gt;✅ Deployed on &lt;a href="https://fly.io/" rel="noopener noreferrer"&gt;Fly.io&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email notifications&lt;/td&gt;
&lt;td&gt;✅ Gmail API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YouTube upload&lt;/td&gt;
&lt;td&gt;✅ OAuth2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stress test (52 files, 13 GB)&lt;/td&gt;
&lt;td&gt;✅ Passed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large file upload via HTTP&lt;/td&gt;
&lt;td&gt;❌ Hangs on 13 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credentials persistence&lt;/td&gt;
&lt;td&gt;❌ Lost on server restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend deployment&lt;/td&gt;
&lt;td&gt;❌ Still localhost&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The project is open source: &lt;a href="https://github.com/maximosovsky/merge-video" rel="noopener noreferrer"&gt;&lt;strong&gt;github.com/maximosovsky/merge-video&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/maximosovsky/merge-video.git
&lt;span class="nb"&gt;cd &lt;/span&gt;merge-video/backend
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
python main.py
&lt;span class="c"&gt;# Open http://localhost:8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or send YouTube links to the Telegram bot: &lt;a href="https://t.me/MergeVideoBot" rel="noopener noreferrer"&gt;@MergeVideoBot&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;This is part 1. The 3 bugs above were just the beginning — I've already hit new ones while deploying and stress-testing. I'll write about those next.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Building something similar? Hit me up in the comments — I'd love to compare notes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ffmpeg</category>
      <category>fastapi</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why the Boston Metro Map Designer Built Me a Calendar</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Thu, 26 Feb 2026 16:44:00 +0000</pubDate>
      <link>https://forem.com/osovsky/why-the-boston-metro-map-designer-built-me-a-calendar-2gab</link>
      <guid>https://forem.com/osovsky/why-the-boston-metro-map-designer-built-me-a-calendar-2gab</guid>
      <description>&lt;p&gt;In 2012, I printed my first multi-year calendar. Two years on one sheet — because the publishing industry only makes calendars for the next 12 months, and I needed to plan further.&lt;/p&gt;

&lt;p&gt;I gave copies to friends and colleagues. One of them — &lt;a href="https://www.linkedin.com/in/evgeniya-shamis-572316/" rel="noopener noreferrer"&gt;Evgeniya Shamis&lt;/a&gt;, a generations researcher — &lt;a href="https://youtu.be/y7rua9C81Ng?si=wgnVbWqXtwPECbqp" rel="noopener noreferrer"&gt;filmed herself using it&lt;/a&gt;. She pinned it to her wall and started marking deadlines across both years. That was the first signal: people actually want to see more than 12 months at a glance.&lt;/p&gt;

&lt;p&gt;The calendar was &lt;a href="https://app.box.com/s/7yqyh8vfphq9hj2lg1jw" rel="noopener noreferrer"&gt;published&lt;/a&gt; through &lt;a href="https://scriber.biz/" rel="noopener noreferrer"&gt;Scriber&lt;/a&gt; in the Russian «Жить интересно!» magazine. But every new year meant manual layout work. I needed a generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Boston Metro Connection
&lt;/h2&gt;

&lt;p&gt;In 2020, &lt;a href="https://www.linkedin.com/in/michael-kvrivishvili-39ab062/" rel="noopener noreferrer"&gt;Michael Kvrivishvili&lt;/a&gt; — the designer of the official Boston Metro map — built a calendar generator specifically for me. His tool used &lt;a href="https://pdfmake.org/" rel="noopener noreferrer"&gt;pdfmake&lt;/a&gt; to create PDFs directly in the browser: you set the number of months and Gantt rows, and it rendered a printable document.&lt;/p&gt;

&lt;p&gt;It worked. But it was a black box — pdfmake handles all the layout internally, so I couldn't control positioning down to the millimeter. And when I started printing on 914mm engineering paper rolls, millimeters mattered.&lt;/p&gt;

&lt;p&gt;So I rewrote it. From scratch. In pure SVG.&lt;/p&gt;

&lt;h2&gt;
  
  
  What WallPlan Does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://osovsky.com/wallplan/" rel="noopener noreferrer"&gt;WallPlan&lt;/a&gt;&lt;/strong&gt; is a browser-based multi-year calendar generator. You choose a duration (1 month to 20 years), a paper format, and a number of Gantt rows — and it renders a printable calendar in three views:&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%2Fgpq4b520foq21lha6lv3.jpg" 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%2Fgpq4b520foq21lha6lv3.jpg" alt="WallPlan — multi-year calendar generator" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;View&lt;/th&gt;
&lt;th&gt;What It Shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vertical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days listed vertically per month — classic planner layout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gantt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Horizontal day grid with project rows — timeline planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Box&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Traditional 7×5 mini-month grids with week numbers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three views render as SVG. You export to SVG or PDF for printing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Paper Formats
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A4 / A3&lt;/td&gt;
&lt;td&gt;Standard office printing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;914mm roll&lt;/td&gt;
&lt;td&gt;Engineering plotter — unlimited length, 1m wide&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;914mm ×2&lt;/td&gt;
&lt;td&gt;Two calendars on one roll&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;914mm ×4&lt;/td&gt;
&lt;td&gt;Four mini-calendars side by side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Yes, I print multi-year calendars at copy centers on engineering paper rolls. A 5-year Gantt calendar on a single continuous sheet, pinned to a wall. That's how I use it for &lt;a href="https://osovsky.medium.com/game-5438c730a15e" rel="noopener noreferrer"&gt;strategic planning&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero Dependencies, Three Files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;index.html    — 22 KB (toolbar, modals, mobile UI, welcome carousel)
calendar.js   — 80 KB (SVG renderer, viewport, export, touch support)
style.css     — 20 KB (all styling, responsive, print CSS)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No React. No build step. No npm install. One &lt;code&gt;npx -y serve -l 3456&lt;/code&gt; and you're running.&lt;/p&gt;

&lt;p&gt;The SVG renderer calculates every coordinate manually — month column widths, day cell sizes, Gantt row heights, page breaks. It's ~2000 lines of vanilla JavaScript that produce pixel-perfect output for any paper format.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why SVG Over Canvas?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vector output&lt;/strong&gt; — PDF stays crisp at any zoom, prints cleanly on plotters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pan and zoom&lt;/strong&gt; — the viewport supports one-finger drag and pinch-to-zoom&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch path optimization&lt;/strong&gt; — thousands of grid lines are merged into 4 &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; elements instead of individual &lt;code&gt;&amp;lt;line&amp;gt;&lt;/code&gt; elements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy PDF export&lt;/strong&gt; — jsPDF + svg2pdf.js (~500KB) load only when you click Download&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Seven weights of &lt;a href="https://fonts.google.com/specimen/IBM+Plex+Sans" rel="noopener noreferrer"&gt;IBM Plex Sans&lt;/a&gt; are fetched in parallel and embedded as base64 into the PDF. The visual style is deliberately Moleskine-inspired: warm cream background, thin ink-like lines, condensed typography.&lt;/p&gt;

&lt;h2&gt;
  
  
  The PDF That Looked Perfect But Printed Garbage
&lt;/h2&gt;

&lt;p&gt;Here's an engineering horror story.&lt;/p&gt;

&lt;p&gt;I generated a PDF — A4, 12 months, 12 Gantt rows. Opened it in Chrome: perfect. Sent it to a colleague via Telegram: preview looked great. Hit Print on a plotter: &lt;strong&gt;complete garbage&lt;/strong&gt;. Vertical lines smashed together into an unreadable mess.&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%2Fq3bo1tg1nkk6f25mt9ig.jpg" 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%2Fq3bo1tg1nkk6f25mt9ig.jpg" alt="PDF printed as garbage — all lines collapsed into each other" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The culprit: &lt;strong&gt;batch path optimization&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;To keep the SVG lightweight, I merge all grid lines into a few &lt;code&gt;&amp;lt;path&amp;gt;&lt;/code&gt; elements with thousands of &lt;code&gt;M&lt;/code&gt;/&lt;code&gt;V&lt;/code&gt;/&lt;code&gt;H&lt;/code&gt; draw commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;M150.3 400V520 M150.55 400V520 M150.8 400V520...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chrome's PDF renderer (PDFium/Skia) handles this flawlessly. But the plotter's RIP (Raster Image Processor) — with its limited firmware — chokes on paths with 3000+ commands. Coordinates lose precision after hundreds of operations, and lines collapse into each other.&lt;/p&gt;

&lt;p&gt;The PDF was &lt;strong&gt;technically valid&lt;/strong&gt; but &lt;strong&gt;too complex&lt;/strong&gt; for certain printers. Like a website that works in Chrome but breaks in Internet Explorer.&lt;/p&gt;

&lt;p&gt;The fix? Still exploring options — from splitting batch paths into smaller chunks, to canvas rasterization at 300 DPI, to a parallel &lt;a href="https://pdfkit.org/" rel="noopener noreferrer"&gt;PDFKit&lt;/a&gt; renderer. The original pdfmake approach from Michael's version never had this problem because pdfmake generates native PDF table elements, not converted SVG paths.&lt;/p&gt;

&lt;h2&gt;
  
  
  Month Colors — The Temperature Palette
&lt;/h2&gt;

&lt;p&gt;Each month can be shaded with a seasonal color palette inspired by Johannes Itten's color wheel. Warm months get warm tones, cold months get cool tones. Toggle it with &lt;code&gt;&amp;amp;c=1&lt;/code&gt; in the URL.&lt;/p&gt;

&lt;p&gt;The palette follows a temperature curve: January is deep blue, July is warm amber, and transitions flow through greens and oranges. It's subtle — just enough color to visually separate months at a glance without overwhelming the print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Entries
&lt;/h2&gt;

&lt;p&gt;Add birthdays, deadlines, or recurring events directly on the calendar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DD.MM  Your text here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entries appear as markers on the corresponding day. Set them to repeat annually, and they show up every year across your entire multi-year span. No login, no cloud — entries live in the URL parameters and localStorage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Miro Integration
&lt;/h2&gt;

&lt;p&gt;WallPlan doesn't just live in the browser. It has a full &lt;a href="https://github.com/maximosovsky/wallplan-miro" rel="noopener noreferrer"&gt;Miro App&lt;/a&gt; that generates calendars as &lt;strong&gt;native Miro board elements&lt;/strong&gt; — not images, but real text objects, shapes, and frames that you can move, edit, and annotate.&lt;/p&gt;

&lt;p&gt;The architecture made this possible: WallPlan's SVG renderer calculates explicit &lt;code&gt;(x, y, width, height)&lt;/code&gt; coordinates for every element. The Miro version reuses the same data layer (&lt;code&gt;calendar-engine.ts&lt;/code&gt;) and maps coordinates to &lt;code&gt;miro.board.createText()&lt;/code&gt; / &lt;code&gt;miro.board.createShape()&lt;/code&gt; calls.&lt;/p&gt;

&lt;p&gt;There's also a &lt;a href="https://miro.com/miroverse/2year-timeline-gantt-calendar-20262027/" rel="noopener noreferrer"&gt;Miroverse template&lt;/a&gt; — a ready-made 2-year Gantt calendar board that anyone can duplicate.&lt;/p&gt;

&lt;p&gt;This is something none of the competitors offer. Calidar.io has beautiful PDF calendars. Kruglendar has elegant circular layouts. Notion Planners sell templated planners on Etsy. But none of them generate native, editable calendar objects inside a collaborative whiteboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LifeLine Connection
&lt;/h2&gt;

&lt;p&gt;WallPlan shares its rendering engine with &lt;a href="https://github.com/maximosovsky/lifeline" rel="noopener noreferrer"&gt;LifeLine&lt;/a&gt;, my life timeline visualization tool. Same SVG core, same viewport system, same IBM Plex Sans fonts, same PDF pipeline.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WallPlan&lt;/strong&gt; → years × months (planning ahead)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LifeLine&lt;/strong&gt; → decades × life categories (looking back and forward)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SVG rendering approach proved in WallPlan — manually calculated coordinates, batch path optimization, lazy PDF export — became the foundation for a completely different product.&lt;/p&gt;

&lt;h2&gt;
  
  
  From 2012 to Now
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Year&lt;/th&gt;
&lt;th&gt;Milestone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2012&lt;/td&gt;
&lt;td&gt;First 2-year calendar printed and distributed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2013&lt;/td&gt;
&lt;td&gt;Published in «Жить интересно!» magazine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2017&lt;/td&gt;
&lt;td&gt;Methodology presented at conferences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2020&lt;/td&gt;
&lt;td&gt;Michael Kvrivishvili builds the pdfmake generator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2024&lt;/td&gt;
&lt;td&gt;Full rewrite to SVG — WallPlan is born&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2025&lt;/td&gt;
&lt;td&gt;Miro App, month colors, custom entries, mobile UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026&lt;/td&gt;
&lt;td&gt;Open source on GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fourteen years from a hand-made calendar to a production tool. The core idea never changed: &lt;strong&gt;see more than 12 months at once&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://osovsky.com/wallplan/" rel="noopener noreferrer"&gt;osovsky.com/wallplan&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/maximosovsky/wallplan" rel="noopener noreferrer"&gt;github.com/maximosovsky/wallplan&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Miro template:&lt;/strong&gt; &lt;a href="https://miro.com/miroverse/2year-timeline-gantt-calendar-20262027/" rel="noopener noreferrer"&gt;2-Year Timeline Gantt Calendar&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; serve &lt;span class="nt"&gt;-l&lt;/span&gt; 3456  &lt;span class="c"&gt;# Open http://localhost:3456&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Building in public, one repo at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built a Life Timeline Tool That Coaches Say You Shouldn't Use</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Wed, 25 Feb 2026 16:44:00 +0000</pubDate>
      <link>https://forem.com/osovsky/i-built-a-life-timeline-tool-that-coaches-say-you-shouldnt-use-16b2</link>
      <guid>https://forem.com/osovsky/i-built-a-life-timeline-tool-that-coaches-say-you-shouldnt-use-16b2</guid>
      <description>&lt;p&gt;In coaching, drawing lifelines is considered taboo. They say it frustrates people — forces them to confront gaps, failures, missed decades.&lt;/p&gt;

&lt;p&gt;I'm not a coach. I'm a strategy consultant. And in strategic sessions, we draw timelines with organizations all the time. One day I thought: why not apply the same tool to personal strategy?&lt;/p&gt;

&lt;p&gt;So I started drawing lifelines on paper with real participants. Decades across the top, life categories down the side, colored markers for events. It worked. People saw their entire life at a glance — patterns they never noticed, gaps they could fill, futures they could plan.&lt;/p&gt;

&lt;p&gt;Then I automated it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What LifeLine Does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://lifeline.osovsky.com" rel="noopener noreferrer"&gt;LifeLine&lt;/a&gt;&lt;/strong&gt; is a browser-based tool that generates a multi-decade timeline with a Gantt-style grid. You mark events, milestones, and periods across 12 life categories — from Happiness to Career to Loss.&lt;/p&gt;

&lt;p&gt;No login. No server. No database. Everything stays in your browser's localStorage.&lt;/p&gt;

&lt;p&gt;The core idea: &lt;strong&gt;see your entire life on one page&lt;/strong&gt; — from birth year to the horizon you choose.&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%2Fd5thd0r0ormvowiv5bds.jpg" 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%2Fd5thd0r0ormvowiv5bds.jpg" alt="LifeLine — life timeline visualization" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  12 Life Categories
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;😊 Happiness&lt;/td&gt;
&lt;td&gt;💜 Relationships&lt;/td&gt;
&lt;td&gt;👶 Children&lt;/td&gt;
&lt;td&gt;🎓 Education&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🏢 Career&lt;/td&gt;
&lt;td&gt;💰 Income&lt;/td&gt;
&lt;td&gt;⛺ Travel&lt;/td&gt;
&lt;td&gt;✏ Hobbies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🏃 Sport&lt;/td&gt;
&lt;td&gt;🏥 Health&lt;/td&gt;
&lt;td&gt;💀 Loss&lt;/td&gt;
&lt;td&gt;⚡ Conflicts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What You Can Do
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Add point events: &lt;code&gt;3, Product launch, 2018&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add bar ranges: &lt;code&gt;4, 1979-1990, School, blue&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Toggle life milestones (statistical ♀/♂ lines with education, career, family)&lt;/li&gt;
&lt;li&gt;Export as SVG or multi-page PDF&lt;/li&gt;
&lt;li&gt;Print on A4 sheets or &lt;strong&gt;914mm plotter rolls&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Yes, I actually print these on a plotter. A 40-year life on a single continuous roll of paper, pinned to a wall. That's how we use them in real sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Paper to Code
&lt;/h2&gt;

&lt;p&gt;The first lifelines were hand-drawn on large paper with colored markers. Participants in strategic sessions would fill in their decades while I facilitated.&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%2Fqigiwu0tsxt4lc7ra760.jpg" 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%2Fqigiwu0tsxt4lc7ra760.jpg" alt="Strategic session — Dushanbe, January 2026" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problem: every session needed a fresh template. Drawing the grid by hand takes 30 minutes. Printing a pre-made template means fixed year ranges.&lt;/p&gt;

&lt;p&gt;So I built a generator. Start year, end year, number of rows — the tool renders a perfect SVG grid that you can fill in digitally or print blank and fill by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Zero Dependencies — By Design
&lt;/h2&gt;

&lt;p&gt;The entire project is three files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;index.html    — 18 KB (toolbar, modals, canvas, mobile UI)
calendar.js   — 84 KB (SVG renderer, viewport, i18n, PDF export)
style.css     — 25 KB (all styling, responsive, mobile)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No React. No build step. No npm install. The whole thing loads instantly and runs on any device with a browser.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why No Framework?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instant load&lt;/strong&gt; — zero bundle, zero hydration, zero FOUC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SVG over Canvas&lt;/strong&gt; — vector output for crisp printing at any scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline-capable&lt;/strong&gt; — works without internet after first visit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easy to host&lt;/strong&gt; — &lt;code&gt;npx -y serve -l 3456&lt;/code&gt; and you're done&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The PDF export lazy-loads jsPDF + svg2pdf.js (~500KB) only when the user clicks "Download PDF". Seven font weights (IBM Plex Sans) are fetched in parallel and embedded as base64.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WallPlan Connection
&lt;/h2&gt;

&lt;p&gt;LifeLine shares DNA with &lt;a href="https://github.com/maximosovsky/wallplan" rel="noopener noreferrer"&gt;WallPlan&lt;/a&gt;, my wall calendar generator. Same SVG rendering engine, same viewport pan/zoom system, same IBM Plex Sans fonts, same PDF export pipeline.&lt;/p&gt;

&lt;p&gt;This wasn't an accident. WallPlan proved that pure SVG + vanilla JS can handle complex multi-page printable layouts. When I needed the same capability for life timelines, I forked the rendering core and adapted it.&lt;/p&gt;

&lt;p&gt;Same engine, different purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WallPlan&lt;/strong&gt; → years × months (planning ahead)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LifeLine&lt;/strong&gt; → decades × life categories (looking back and forward)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Mobile-First, Touch-Native
&lt;/h2&gt;

&lt;p&gt;The mobile UI uses a bottom bar + bottom sheet pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One-finger pan, two-finger pinch-to-zoom&lt;/li&gt;
&lt;li&gt;Paper format toggle (A4 / plotter roll)&lt;/li&gt;
&lt;li&gt;Column width selection (1cm / 1.5cm / 2cm per year)&lt;/li&gt;
&lt;li&gt;Entry input via a full-screen modal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything renders as SVG with &lt;code&gt;position: fixed&lt;/code&gt; pages — smooth pan/zoom without DOM reflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Built-in i18n
&lt;/h2&gt;

&lt;p&gt;The tool ships with English and Russian interfaces. Language auto-detects on load. Every string — from decade labels to tooltip text to the sticky-note category cheatsheet — is localized through a built-in &lt;code&gt;t('key')&lt;/code&gt; function. No i18n library needed.&lt;/p&gt;

&lt;p&gt;Russian version lives at &lt;a href="https://lifeline.osovsky.com/ru" rel="noopener noreferrer"&gt;lifeline.osovsky.com/ru&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publications &amp;amp; Lectures
&lt;/h2&gt;

&lt;p&gt;This isn't a weekend project. The methodology behind LifeLine has been presented at conferences and published since 2017:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images of the Future &amp;amp; Graphic Thinking&lt;/strong&gt; — Kirov, 2017&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Career Development of a Top Manager&lt;/strong&gt; — Sochi, 2018&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifeline &amp;amp; Schematization in Coaching&lt;/strong&gt; — Seminar, 2018&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forum of Ideas&lt;/strong&gt; — Moscow, 2020&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://osowski.medium.com/calendar-392272c97af3" rel="noopener noreferrer"&gt;Expanding Planning Horizons&lt;/a&gt;&lt;/strong&gt; — Medium article&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The concept: &lt;em&gt;"Lines and structures are a language for making sense of your life — just as a blueprint is the language of an engineer, a map is the language of a geographer, and a Gantt chart is the language of a manager."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Live:&lt;/strong&gt; &lt;a href="https://lifeline.osovsky.com" rel="noopener noreferrer"&gt;lifeline.osovsky.com&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/maximosovsky/lifeline" rel="noopener noreferrer"&gt;github.com/maximosovsky/lifeline&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;-y&lt;/span&gt; serve &lt;span class="nt"&gt;-l&lt;/span&gt; 3456  &lt;span class="c"&gt;# Open http://localhost:3456&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;&lt;em&gt;Building in public, one repo at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Your README Is Your Book Cover. Here's the Checklist I Use for 50+ Projects</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Tue, 24 Feb 2026 16:44:00 +0000</pubDate>
      <link>https://forem.com/osovsky/your-readme-is-your-book-cover-heres-the-checklist-i-use-for-50-projects-7m8</link>
      <guid>https://forem.com/osovsky/your-readme-is-your-book-cover-heres-the-checklist-i-use-for-50-projects-7m8</guid>
      <description>&lt;p&gt;Open any random GitHub repo. Chances are, the README is either a wall of text, a default &lt;code&gt;create-react-app&lt;/code&gt; placeholder, or just empty.&lt;/p&gt;

&lt;p&gt;Now imagine you manage 50+ repositories. Every single one needs a README that looks professional, explains what the project does, and helps new contributors (or your AI assistant) get up to speed in seconds.&lt;/p&gt;

&lt;p&gt;I got tired of reinventing this every time. So I turned my README rules into a standalone project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Most developers treat READMEs as an afterthought. "I'll write it later." Later never comes.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;a README is a book cover.&lt;/strong&gt; If it's boring, nobody opens the project. No stars, no contributors, no adoption. Even if the code inside is brilliant.&lt;/p&gt;

&lt;p&gt;And there's a second audience that nobody thinks about: &lt;strong&gt;AI.&lt;/strong&gt; When you ask an LLM to help with your project, the first thing it reads is the README. A bad README means bad AI suggestions. A structured README means the AI understands your project instantly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Systematized
&lt;/h2&gt;

&lt;p&gt;After writing and rewriting READMEs for 50+ projects, I noticed that the same patterns kept working. The surprise? &lt;strong&gt;README quality can be systematized.&lt;/strong&gt; It's not art — it's a checklist.&lt;/p&gt;

&lt;p&gt;Here's the 14-point structure I settled on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1.  Centered header + logo
2.  Badges (status, license, stack)
3.  Tagline (1 line)
4.  Inline navigation (Quick Start · Features · Docs)
5.  Preview screenshot / GIF
    ---
6.  💡 Concept (blockquote + explanation)
    ---
7.  ✨ Features (table, not bullet list)
    ---
8.  🚀 Quick Start (3 lines of code)
    &amp;lt;details&amp;gt; Advanced setup &amp;lt;/details&amp;gt;
    ---
9.  🏗️ Tech Stack (table + file tree)
    ---
10. 🗺️ Roadmap (task checklist)
    ---
11. 🤝 Contributing (fork → branch → PR)
    ---
12. 📄 License + author
13. llms.txt + llms-full.txt (LLM-readable docs)
14. GitHub About: Description, Website, Topics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not every project needs all 14 points. But having a checklist means you never forget the important ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to Use (and What to Avoid)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ✅ Use
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;div align="center"&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Centered headers look professional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;style=for-the-badge&lt;/code&gt; badges&lt;/td&gt;
&lt;td&gt;Consistent, eye-catching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;img width="600"&amp;gt;&lt;/code&gt; preview&lt;/td&gt;
&lt;td&gt;Shows what the project looks like&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;details&amp;gt;&lt;/code&gt; collapsibles&lt;/td&gt;
&lt;td&gt;Hides secondary info, keeps README scannable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature tables&lt;/td&gt;
&lt;td&gt;Easier to scan than bullet lists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;---&lt;/code&gt; dividers&lt;/td&gt;
&lt;td&gt;Visual separation between sections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;[!NOTE]&lt;/code&gt; / &lt;code&gt;[!WARNING]&lt;/code&gt; alerts&lt;/td&gt;
&lt;td&gt;GitHub renders them beautifully&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;llms.txt&lt;/code&gt; at root&lt;/td&gt;
&lt;td&gt;AI can discover and understand your project&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  ❌ Avoid
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wall of text&lt;/strong&gt; — nobody reads unformatted prose&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No preview image&lt;/strong&gt; — unclear what the project looks like&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No badges&lt;/strong&gt; — looks like an unfinished project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Quick Start&lt;/strong&gt; — people leave if they can't run it in 30 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardcoded secrets&lt;/strong&gt; — even &lt;code&gt;sk_test_...&lt;/code&gt; alarms reviewers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No license&lt;/strong&gt; — legally unclear whether the code can be used&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead links&lt;/strong&gt; — broken URLs kill trust&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;© symbol&lt;/strong&gt; — just use &lt;code&gt;Author Name. Licensed under MIT.&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty About section&lt;/strong&gt; — repo won't appear in GitHub search&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The LLM Angle: llms.txt
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://llmstxt.org/" rel="noopener noreferrer"&gt;llms.txt standard&lt;/a&gt; is simple: put a plain-text file at your repo root that explains the project in a format optimized for AI.&lt;/p&gt;

&lt;p&gt;I add two files to every project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;llms.txt&lt;/code&gt;&lt;/strong&gt; — short summary (name, purpose, stack, status)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;llms-full.txt&lt;/code&gt;&lt;/strong&gt; — extended context (architecture, API, deployment)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Does it make a difference? When I give my AI assistant a project with &lt;code&gt;llms.txt&lt;/code&gt;, it understands the codebase faster and gives better suggestions. Without it, the AI guesses — and often guesses wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  CI: Auto-Check Your README
&lt;/h2&gt;

&lt;p&gt;One file, zero cost, and GitHub checks every push:&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="c1"&gt;# .github/workflows/readme-lint.yml&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;README Lint&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&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;links&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;lycheeverse/lychee-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;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;--verbose *.md&lt;/span&gt;
  &lt;span class="na"&gt;markdown&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DavidAnson/markdownlint-cli2-action@v16&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;globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.md"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🔗 Dead links (404s)&lt;/li&gt;
&lt;li&gt;🏷️ Broken badge images&lt;/li&gt;
&lt;li&gt;📝 Markdown syntax errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Free for public repos. 2,000 minutes/month for private.&lt;/p&gt;

&lt;h2&gt;
  
  
  Templates
&lt;/h2&gt;

&lt;p&gt;The repo includes two ready-to-use templates:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/maximosovsky/readme-guidelines/blob/main/TEMPLATE.md" rel="noopener noreferrer"&gt;TEMPLATE.md&lt;/a&gt;&lt;/strong&gt; — full 14-point structure with &lt;code&gt;{PLACEHOLDERS}&lt;/code&gt;. Copy, rename to &lt;code&gt;README.md&lt;/code&gt;, fill in the blanks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/maximosovsky/readme-guidelines/blob/main/TEMPLATE-minimal.md" rel="noopener noreferrer"&gt;TEMPLATE-minimal.md&lt;/a&gt;&lt;/strong&gt; — 5 essential sections for quick projects.&lt;/p&gt;

&lt;p&gt;Both follow the same principles. Both work as instructions for AI — paste the template into your prompt and the LLM generates a structured README.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Actually Use It
&lt;/h2&gt;

&lt;p&gt;My workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start a new project&lt;/li&gt;
&lt;li&gt;Copy &lt;code&gt;TEMPLATE.md&lt;/code&gt; → &lt;code&gt;README.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tell the AI: &lt;em&gt;"Fill this README using the project's source code and &lt;code&gt;README_GUIDELINES.md&lt;/code&gt;"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Review, tweak, push&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The AI reads the guidelines, follows the structure, and produces a README that looks like I spent an hour on it. In reality, it takes 2 minutes.&lt;/p&gt;

&lt;p&gt;For 50+ projects, that's not a productivity hack — it's a survival strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Examples
&lt;/h2&gt;

&lt;p&gt;Projects built with these guidelines:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;th&gt;What It Is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/wallplan" rel="noopener noreferrer"&gt;WallPlan&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Calendar wall planner generator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/lifeline" rel="noopener noreferrer"&gt;Lifeline&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Interactive timeline builder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/teleinviter" rel="noopener noreferrer"&gt;TeleInviter&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Telegram meeting bot with timezones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/maximosovsky/DelayedPopup" rel="noopener noreferrer"&gt;DelayedPopup&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Smart popup timing library&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one follows the same structure. Every one looks professional at first glance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/maximosovsky/readme-guidelines" rel="noopener noreferrer"&gt;github.com/maximosovsky/readme-guidelines&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Grab a template, point your AI at the guidelines, and stop writing READMEs from scratch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;While writing this article, my &lt;a href="https://github.com/maximosovsky/water-reminder" rel="noopener noreferrer"&gt;Water Reminder&lt;/a&gt; popped up and I took a water break. If you're deep in code right now — go drink a glass of water. You'll thank me later.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building in public, one repo at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>github</category>
      <category>opensource</category>
      <category>documentation</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built a Telegram Bot That Solves the "What Time Is It For You?" Problem</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Mon, 23 Feb 2026 16:44:00 +0000</pubDate>
      <link>https://forem.com/osovsky/i-built-a-telegram-bot-that-solves-the-what-time-is-it-for-you-problem-3dmj</link>
      <guid>https://forem.com/osovsky/i-built-a-telegram-bot-that-solves-the-what-time-is-it-for-you-problem-3dmj</guid>
      <description>&lt;p&gt;"Can we do 6 PM your time? Wait, what time zone are you in again?"&lt;/p&gt;

&lt;p&gt;I've had this conversation hundreds of times. I work with people across 7 timezones — from Los Angeles to Bishkek. Every meeting started the same way: open &lt;a href="https://www.worldtimebuddy.com/" rel="noopener noreferrer"&gt;World Time Buddy&lt;/a&gt;, convert times, paste the result into Telegram, hope nobody gets confused.&lt;/p&gt;

&lt;p&gt;But the real problem wasn't the conversion. It was everything that happens after.&lt;/p&gt;

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

&lt;p&gt;Meetings happen in messengers. Not everyone knows each other's email. So nobody adds you to Google Calendar. You get a Zoom link in a Telegram chat, and that's it — no reminder, no calendar event, just a message you'll scroll past and forget.&lt;/p&gt;

&lt;p&gt;I needed three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automatic timezone conversion&lt;/strong&gt; — type the time once, everyone sees it in their city&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Google Calendar link&lt;/strong&gt; — one click to add the meeting, no email required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DM reminders&lt;/strong&gt; — because group chat messages get buried&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I built a Telegram bot that does all three from a single message.&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;Send the bot one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Strategy Call, 15.03.2026, 18:00, https://zoom.us/j/123456, @alex @maria
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It responds with a card like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;📅 Strategy Call
📆 Saturday, 15 March 2026

🕐 17:00 Riga; Tel-Aviv
🕐 17:00 Rome
🕐 18:00 Istanbul
🕐 20:00 Bishkek
🕐 01:00 Beijing (+1 day)
🕐 08:00 Los Angeles

🔗 https://zoom.us/j/123456

📲 Add to Calendar  ❌ Cancel Reminder
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, 45 minutes before the meeting, every participant gets a personal DM: &lt;em&gt;"Reminder: Strategy Call starts in 45 minutes."&lt;/em&gt; And another one 5 minutes before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack (All Free Tier)
&lt;/h2&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;Role&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;&lt;a href="https://vercel.com/" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Hosting (serverless)&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://upstash.com/" rel="noopener noreferrer"&gt;Upstash QStash&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Delayed reminders&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://upstash.com/" rel="noopener noreferrer"&gt;Upstash Redis&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;User allowlist&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.telethon.dev/" rel="noopener noreferrer"&gt;Telethon&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Personal DMs&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/eternnoir/pyTelegramBotAPI" rel="noopener noreferrer"&gt;pyTelegramBotAPI&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Bot interface&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 infrastructure cost: $0/month.&lt;/strong&gt; Everything runs on free tiers.&lt;/p&gt;

&lt;p&gt;The entire bot lives in one file — &lt;code&gt;api/index.py&lt;/code&gt;. Deploy with &lt;code&gt;git push&lt;/code&gt;. No servers, no Docker, no maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hard Parts
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why Telethon (and why it's risky)
&lt;/h3&gt;

&lt;p&gt;Telegram Bot API has a fundamental limitation: &lt;strong&gt;bots can't DM users who haven't started a conversation with the bot.&lt;/strong&gt; So if I invite &lt;code&gt;@alex&lt;/code&gt; to a meeting, the bot can't send Alex a reminder — Alex never messaged the bot.&lt;/p&gt;

&lt;p&gt;The workaround: &lt;a href="https://docs.telethon.dev/" rel="noopener noreferrer"&gt;Telethon&lt;/a&gt;, a userbot library. It sends DMs from a real Telegram account, not a bot. This bypasses the restriction — but Telegram actively fights spam from userbots. Send too many DMs too fast, and your account gets a &lt;code&gt;FloodWaitError&lt;/code&gt; or worse — a permanent ban.&lt;/p&gt;

&lt;p&gt;My AI assistant kept warning me: &lt;em&gt;"Don't use Telethon, Telegram will block your account."&lt;/em&gt; I used it anyway, with rate limiting and careful batching. So far, no ban.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 64-Byte Bug
&lt;/h3&gt;

&lt;p&gt;Telegram limits &lt;code&gt;callback_data&lt;/code&gt; (the hidden payload in inline buttons) to 64 bytes. &lt;a href="https://upstash.com/" rel="noopener noreferrer"&gt;QStash&lt;/a&gt; message IDs are long. So when the bot tried to create a "Cancel Reminder" button with the full QStash ID, Telegram threw &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix: store a short numeric key (&lt;code&gt;cancel:1&lt;/code&gt;, &lt;code&gt;cancel:2&lt;/code&gt;) in the button, and map it to the full QStash ID server-side. Simple, but it took a debugging session to figure out — we only saw the real error after adding detailed error messages to the bot's responses.&lt;/p&gt;

&lt;h3&gt;
  
  
  DST Is a Nightmare
&lt;/h3&gt;

&lt;p&gt;Timezone conversion sounds simple until you hit Daylight Saving Time. Riga and Tel-Aviv are sometimes in the same timezone, sometimes not. The bot uses Python's &lt;code&gt;zoneinfo&lt;/code&gt; with IANA timezone database — proper DST-aware conversion, not hardcoded UTC offsets.&lt;/p&gt;

&lt;p&gt;When two cities end up at the same time, the bot merges them: &lt;code&gt;17:00 Riga; Tel-Aviv&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User → Telegram → Vercel (webhook)
                       │
                  api/index.py
                  (Flask + Bot)
                       │
        ┌──────────────┼──────────────┐
        │              │              │
   Upstash Redis    QStash      Telegram API
   (allowlist)   (2 reminders)  (card + buttons)
                       │
                  ┌────┴────┐
                  │         │
             -45 min    -5 min
                  │         │
             /reminder  /reminder
                  │         │
              Bot msg    Bot msg
              + DMs      + DMs
              (Telethon) (Telethon)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Security layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webhook verification via &lt;code&gt;X-Telegram-Bot-Api-Secret-Token&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Reminder endpoint protected by custom &lt;code&gt;X-Reminder-Secret&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;Dynamic allowlist in Redis (only approved users can create meetings)&lt;/li&gt;
&lt;li&gt;Admin roles for managing the allowlist&lt;/li&gt;
&lt;li&gt;Input sanitization with &lt;code&gt;html.escape()&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Infrastructure beats features.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The bot has ~10 features (timezones, reminders, DMs, calendar, allowlist, admin roles, cancel buttons). But the real value is the architecture: one file, zero cost, deploy with &lt;code&gt;git push&lt;/code&gt;. Features are easy to add when the foundation is solid.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Free tiers are surprisingly generous.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;After 2 weeks of usage: 37 QStash messages total, 16 Redis commands, 0 bandwidth. The free tier limits (1,000 QStash messages/day, 500K Redis commands/month) are absurd overkill for a personal tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The Telethon trade-off is real.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Userbot DMs are the killer feature — and the biggest risk. Telegram can ban your account at any time. For a personal tool, the risk is acceptable. For a product with thousands of users, you'd need a different approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. AI debugging is best when it shows, not tells.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt; bug was invisible — we only saw "⚠️ Reminder failed." The fix wasn't in the logic; it was adding &lt;code&gt;str(e)&lt;/code&gt; to the error message so we could see the real problem in Telegram. AI suggested this in one step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;The bot is live: &lt;a href="https://t.me/inviterLinkBot" rel="noopener noreferrer"&gt;@inviterLinkBot&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Source code: &lt;a href="https://github.com/maximosovsky/teleinviter" rel="noopener noreferrer"&gt;github.com/maximosovsky/teleinviter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Deploy your own in 5 minutes — you'll need a &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;Telegram Bot Token&lt;/a&gt;, an &lt;a href="https://upstash.com/" rel="noopener noreferrer"&gt;Upstash&lt;/a&gt; account, and a &lt;a href="https://vercel.com/" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt; account. All free.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building in public, one bot at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>serverless</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Asked My AI to Remind Me to Drink Water. It Built a Product Instead.</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Sun, 22 Feb 2026 17:09:22 +0000</pubDate>
      <link>https://forem.com/osovsky/i-asked-my-ai-to-remind-me-to-drink-water-it-built-a-product-instead-19ec</link>
      <guid>https://forem.com/osovsky/i-asked-my-ai-to-remind-me-to-drink-water-it-built-a-product-instead-19ec</guid>
      <description>&lt;p&gt;I was 62 minutes into a coding session when I asked my AI assistant: &lt;strong&gt;"Do you even have a rule to remind me about water?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It did. It just couldn't do anything about it — AI only responds when you talk to it. It can't push notifications.&lt;/p&gt;

&lt;p&gt;Drinking water is one of those things everyone knows they should do but nobody actually does during deep work. So instead of relying on a rule the AI couldn't enforce, we built a tool that can.&lt;/p&gt;

&lt;p&gt;That conversation turned into an open-source product in under 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Developers know they should hydrate. We install apps, set phone timers, buy fancy water bottles. But when you're deep in code, you dismiss everything. Phone buzzes? Ignored. Browser tab notification? Closed without reading.&lt;/p&gt;

&lt;p&gt;What you &lt;strong&gt;can't&lt;/strong&gt; ignore is a window that appears &lt;strong&gt;on top of everything&lt;/strong&gt; and won't go away until you click OK.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Request to Script (5 Minutes)
&lt;/h2&gt;

&lt;p&gt;I said: &lt;em&gt;"Write a PowerShell script that shows a popup every 40 minutes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The AI's first version used Windows balloon notifications — those small toasts in the bottom-right corner. I tested it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It disappeared on its own after 5 seconds.&lt;/strong&gt; Useless.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🧑 "Make the window 5x bigger."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The AI switched from balloon notifications to &lt;a href="https://learn.microsoft.com/en-us/dotnet/desktop/winforms/" rel="noopener noreferrer"&gt;WinForms&lt;/a&gt; — a proper GUI window with dark theme, centered on screen, &lt;code&gt;TopMost = $true&lt;/code&gt;. No more missed reminders.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Script: 90 Lines of PowerShell
&lt;/h2&gt;

&lt;p&gt;The entire thing is one file — &lt;code&gt;water-reminder.ps1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="kr"&gt;param&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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Interval&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;40&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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Water Reminder"&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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Drink a glass of water!"&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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Subtitle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Take a 10 minute break"&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="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Emoji&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;ConvertFromUtf32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x1F4A7&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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Width&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&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="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Height&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&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;Every parameter is configurable. Want a stretch reminder every 25 minutes? A Pomodoro break timer? Just change the flags:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;powershell&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-File&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;water-reminder.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Interval&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;25&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Stretch!"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Title&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Break Time"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Dark UI
&lt;/h3&gt;

&lt;p&gt;The popup uses a dark theme that doesn't blind you during late-night sessions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background: &lt;code&gt;rgb(30, 30, 40)&lt;/code&gt; — almost black&lt;/li&gt;
&lt;li&gt;Accent: &lt;code&gt;rgb(100, 180, 255)&lt;/code&gt; — soft blue&lt;/li&gt;
&lt;li&gt;Typography: Segoe UI with a 48pt emoji on top&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/maximosovsky/water-reminder" rel="noopener noreferrer"&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%2F5nwzk4wt893lcqeikwml.jpg" alt="Water Reminder popup" width="800" height="449"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No Electron. No Node. No dependencies. Just PowerShell and .NET assemblies that ship with every Windows installation.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Loop
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="kr"&gt;while&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;$true&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="n"&gt;Start-Sleep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Seconds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$Interval&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$time&lt;/span&gt;&lt;span class="s2"&gt;] Reminder #&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Show-Reminder&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;That's it. Sleep → popup → repeat. The counter logs to console so you can see how many reminders you've gotten today.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Script to Product (20 Minutes)
&lt;/h2&gt;

&lt;p&gt;After testing it for a few minutes, I said: &lt;strong&gt;"Turn this into a product so others can use it too."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Created a project folder with proper structure&lt;/li&gt;
&lt;li&gt;Wrote a &lt;a href="https://github.com/maximosovsky/water-reminder" rel="noopener noreferrer"&gt;README&lt;/a&gt; following my &lt;a href="https://github.com/maximosovsky/readme-guidelines" rel="noopener noreferrer"&gt;readme-guidelines&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Added an MIT license&lt;/li&gt;
&lt;li&gt;Created &lt;a href="https://github.com/maximosovsky/water-reminder/blob/master/ARCHITECTURE.md" rel="noopener noreferrer"&gt;ARCHITECTURE.md&lt;/a&gt; documenting every parameter&lt;/li&gt;
&lt;li&gt;Published to GitHub with topics for discoverability&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One command to publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;gh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;repo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;water-reminder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--public&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--source&lt;/span&gt;&lt;span class="o"&gt;=.&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--push&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--description&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Desktop reminder to drink water and take breaks"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  VS Code Auto-Start (The Best Part)
&lt;/h2&gt;

&lt;p&gt;I didn't want to remember to launch the script every morning. So I added a &lt;a href="https://code.visualstudio.com/docs/editor/tasks" rel="noopener noreferrer"&gt;VS Code task&lt;/a&gt; that starts it automatically when I open my workspace:&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;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Water Reminder"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"shell"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"powershell -File water-reminder.ps1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"presentation"&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;"reveal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"silent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"panel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dedicated"&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;"isBackground"&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;"runOptions"&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;"runOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"folderOpen"&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;Open VS Code → reminder starts silently in a background terminal → first popup in 40 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-time activation&lt;/strong&gt;: &lt;code&gt;Ctrl+Shift+P&lt;/code&gt; → &lt;em&gt;Tasks: Manage Automatic Tasks in Folder&lt;/em&gt; → &lt;em&gt;Allow&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Actually Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The best tools solve your own problems first.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't set out to build a product. I just wanted a reliable reminder to drink water during long coding sessions. The product came second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. AI can't compensate for missing infrastructure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My AI had a rule to remind me about water, but it physically can't push notifications. Recognizing the gap between "capability" and "infrastructure" is the real skill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. PowerShell is underrated for desktop utilities.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No install. No dependencies. No build step. Double-click and it works on every Windows machine since Windows 7. For small tools, this beats Electron by 100x.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Productizing a script takes 20 minutes, not 2 days.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;README + LICENSE + GitHub repo + topics. That's the minimum viable product for an open-source utility. If your tool works, ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;git&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;clone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://github.com/maximosovsky/water-reminder.git&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;powershell&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-File&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;water-reminder.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Star the repo if you forget to drink water too: &lt;a href="https://github.com/maximosovsky/water-reminder" rel="noopener noreferrer"&gt;github.com/maximosovsky/water-reminder&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building in public, one utility at a time. Follow the journey: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://github.com/maximosovsky" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>powershell</category>
      <category>productivity</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Tried to Build a One-Click Deployer — Here's What Actually Happened</title>
      <dc:creator>Maxim Osovsky</dc:creator>
      <pubDate>Sun, 22 Feb 2026 11:48:05 +0000</pubDate>
      <link>https://forem.com/osovsky/i-tried-to-build-a-one-click-deployer-heres-what-actually-happened-o9d</link>
      <guid>https://forem.com/osovsky/i-tried-to-build-a-one-click-deployer-heres-what-actually-happened-o9d</guid>
      <description>&lt;h2&gt;
  
  
  The Original Idea
&lt;/h2&gt;

&lt;p&gt;The hypothesis was simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a project runs on &lt;a href="https://vercel.com" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt;, it's just static files — HTML, JS, CSS. If it's static, you can host it &lt;strong&gt;anywhere&lt;/strong&gt;. Any CDN. Any object storage. Any cloud.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I came up with &lt;a href="https://maximosovsky.github.io/deploy-bridge/" rel="noopener noreferrer"&gt;&lt;strong&gt;DeployBridge&lt;/strong&gt;&lt;/a&gt; — an open-source service where you paste four things into a form:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;GitHub repo URL&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vercel token&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alibaba Cloud token&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Domain name&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hit "Deploy." The service clones the repo, runs &lt;code&gt;npm run build&lt;/code&gt;, takes the output files, and uploads them to &lt;a href="https://www.alibabacloud.com/product/object-storage-service" rel="noopener noreferrer"&gt;Alibaba Cloud OSS&lt;/a&gt; (object storage). No server. No Nginx. No VPS. Just files on a CDN with a custom domain. Done in 30 seconds.&lt;/p&gt;

&lt;p&gt;The logic felt bulletproof: &lt;strong&gt;GitHub repo → &lt;code&gt;npm run build&lt;/code&gt; → upload &lt;code&gt;dist/&lt;/code&gt; to Alibaba OSS → DNS → live site.&lt;/strong&gt; Five API calls, all automated.&lt;/p&gt;

&lt;p&gt;I even had the complexity estimated at &lt;strong&gt;3–4 out of 10.&lt;/strong&gt; How hard can it be?&lt;/p&gt;

&lt;p&gt;Then I tried to actually deploy something. It took 1.5 hours, 15 steps, 3 different admin consoles, and 8 gotchas I never saw coming.&lt;/p&gt;

&lt;p&gt;Here's what happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Promise
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://maximosovsky.github.io/deploy-bridge/" rel="noopener noreferrer"&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%2Fjdnlrpj4c3vr0o46q2gd.jpg" alt="DeployBridge landing page — " width="800" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🤖 &lt;a href="https://blog.google/technology/google-deepmind/antigravity-ai-coding/" rel="noopener noreferrer"&gt;&lt;strong&gt;Antigravity&lt;/strong&gt;&lt;/a&gt; built the landing page with a Luma-inspired aesthetic — glassmorphism cards, pastel gradients, subtle animations. It looked professional. Premium, even.&lt;/p&gt;

&lt;p&gt;The form. The progress bar. Five steps animating beautifully:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;✅ Cloning repository&lt;/li&gt;
&lt;li&gt;✅ Building project&lt;/li&gt;
&lt;li&gt;✅ Deploying to Vercel&lt;/li&gt;
&lt;li&gt;⏳ Configuring DNS on Alibaba Cloud&lt;/li&gt;
&lt;li&gt;⬜ Verifying domain &amp;amp; SSL&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then came the moment of truth: I needed to actually deploy &lt;a href="https://www.osovsky.com/wallplan/" rel="noopener noreferrer"&gt;WallPlan&lt;/a&gt;, my calendar generator, to Alibaba Cloud.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Reality
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;🧑 = me (human) — console clicks, purchases, DNS records&lt;br&gt;
🤖 = Antigravity (AI) — scripts, API calls, debugging&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Step 1: Create a RAM User (10 min)
&lt;/h3&gt;

&lt;p&gt;🧑 You can't just use your root Alibaba account for API access. You need a &lt;strong&gt;&lt;a href="https://www.alibabacloud.com/product/ram" rel="noopener noreferrer"&gt;RAM (Resource Access Management)&lt;/a&gt; user&lt;/strong&gt; — their equivalent of AWS IAM.&lt;/p&gt;

&lt;p&gt;I navigated to &lt;strong&gt;Alibaba Cloud Console → RAM → Identities → Users → Create User&lt;/strong&gt;, and attached the &lt;code&gt;AliyunOSSFullAccess&lt;/code&gt; policy. There's a picker with 56 pages of policies. Fun.&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%2F9tty2rimscz00u8tc3y2.jpg" 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%2F9tty2rimscz00u8tc3y2.jpg" alt="Attaching the AliyunOSSFullAccess policy to a RAM user" width="772" height="817"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After creating the user, you get an AccessKey ID and Secret. The Secret is shown &lt;strong&gt;exactly once&lt;/strong&gt;. If you miss it, create a new one.&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%2F89sonwgzttnc67gsz2zy.jpg" 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%2F89sonwgzttnc67gsz2zy.jpg" alt="AccessKey created — shown once, then hidden forever" width="800" height="142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #1&lt;/strong&gt;: The RAM user was created, but I still couldn't do anything. Turns out you need to grant permissions in a &lt;em&gt;separate step&lt;/em&gt; — creating the user alone isn't enough.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Step 2: Activate OSS ($0.00 Purchase Flow)
&lt;/h3&gt;

&lt;p&gt;🧑 OSS (Object Storage Service) isn't enabled by default. You need to "purchase" it. For $0.00. With a credit card.&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%2Fu9falg733kq59xluqql8.jpg" 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%2Fu9falg733kq59xluqql8.jpg" alt="OSS is not activated yet — Enable Now button" width="800" height="176"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Clicking "Enable Now" takes you to a checkout page. Total: $0.00. Payment method: VISA. You still need to click "Purchase."&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%2Fp98ph5rvbvmx97var9z8.jpg" 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%2Fp98ph5rvbvmx97var9z8.jpg" alt="The $0.00 purchase flow for OSS activation" width="800" height="340"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After the "purchase," you get a success page. Congratulations, you've bought nothing.&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%2Fpsi81p5qnan0ar3cbwcu.jpg" 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%2Fpsi81p5qnan0ar3cbwcu.jpg" alt="OSS successfully activated" width="800" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #2&lt;/strong&gt;: Even though the service is free to activate, there's a multi-step purchase flow with payment method selection. This can't be automated via API.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Step 3: Deploy the Files (2 min ✅)
&lt;/h3&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; wrote a Node.js script using the &lt;a href="https://www.npmjs.com/package/ali-oss" rel="noopener noreferrer"&gt;&lt;code&gt;ali-oss&lt;/code&gt;&lt;/a&gt; SDK. I just hit Enter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OSS&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;oss-ap-southeast-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALI_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;accessKeySecret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ALI_ACCESS_KEY_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wallplan-deploy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Upload 23 files: HTML, JS, CSS, fonts, images&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wallplan/@yka_yka/index.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html; charset=utf-8&lt;/span&gt;&lt;span class="dl"&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;23 files uploaded. Bucket created. Static hosting enabled. All good.&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%2F318pyt7x7vqwc2p34egn.jpg" 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%2F318pyt7x7vqwc2p34egn.jpg" alt="Bucket wallplan-deploy in OSS Console with uploaded files" width="800" height="334"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 4: Try to Open the URL... 💀
&lt;/h3&gt;

&lt;p&gt;🧑 I clicked the URL. The HTML file &lt;strong&gt;downloaded&lt;/strong&gt; instead of rendering in the browser.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Not this --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;WallPlan Calendar&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- This --&amp;gt;&lt;/span&gt;
Save as: index.html (19KB)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; re-uploaded the file with explicit &lt;code&gt;Content-Type: text/html&lt;/code&gt; and &lt;code&gt;Content-Disposition: inline&lt;/code&gt; headers. Headers looked correct. Still downloading.&lt;/p&gt;

&lt;p&gt;Turns out the problem wasn't the headers at all.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #3&lt;/strong&gt;: Alibaba Cloud OSS &lt;strong&gt;forces file downloads&lt;/strong&gt; on the default &lt;code&gt;*.aliyuncs.com&lt;/code&gt; domain. This is a security policy, not a bug. You &lt;em&gt;must&lt;/em&gt; bind a custom domain. The documentation mentions this in a footnote somewhere. Nowhere is it prominently displayed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This single gotcha invalidated the entire "one-click" concept. Without a custom domain, &lt;em&gt;nothing works&lt;/em&gt; in a browser.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 5: Find Your DNS Provider (1 min)
&lt;/h3&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; ran a quick check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nslookup &lt;span class="nt"&gt;-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NS osovsky.com
&lt;span class="c"&gt;# → ns31.domaincontrol.com → GoDaddy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My domain was on GoDaddy. In the "one-click" vision, the system would somehow need to handle this automatically for every possible DNS provider.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 6: GoDaddy API... Doesn't Work (15 min)
&lt;/h3&gt;

&lt;p&gt;🧑 I created a Production API key at &lt;a href="https://developer.godaddy.com/keys" rel="noopener noreferrer"&gt;developer.godaddy.com/keys&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; wrote a script to add a CNAME record via the GoDaddy API. The API returned:&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="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;"ACCESS_DENIED"&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;"Authenticated user is not allowed access"&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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #4&lt;/strong&gt;: GoDaddy's free tier doesn't give actual API access to manage DNS records. The API key creation page doesn't tell you this. You create the key, it looks valid, and then... AccessDenied.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;🧑 Had to add the CNAME record manually through the GoDaddy mobile app. At 2 AM.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 7: Domain Verification (20 min)
&lt;/h3&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; wrote a &lt;code&gt;bind-domain.js&lt;/code&gt; script to bind &lt;code&gt;ali.osovsky.com&lt;/code&gt; to the OSS bucket. New error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NeedVerifyDomainOwnership: Please verify domain ownership 
by CreateCnameToken and try again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OSS needs proof that you own the domain. Fair enough. But the verification requires adding a TXT record — and &lt;strong&gt;DNS doesn't allow a CNAME and TXT record on the same hostname&lt;/strong&gt;.&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%2Fppccn9rhl1f79lv6rsy9.jpg" 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%2Fppccn9rhl1f79lv6rsy9.jpg" alt="GoDaddy showing a conflict between TXT and CNAME records on the same name" width="460" height="1024"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; parsed the API response and figured out the TXT record goes to &lt;code&gt;_dnsauth.ali.osovsky.com&lt;/code&gt;, not &lt;code&gt;ali.osovsky.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;🧑 I added the TXT record manually in GoDaddy.&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; ran the bind script again — domain bound successfully.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #5&lt;/strong&gt;: The initial error message is misleading. The TXT record goes to &lt;code&gt;_dnsauth.{subdomain}&lt;/code&gt;, which &lt;em&gt;doesn't&lt;/em&gt; conflict with CNAME. But you only learn this by reading the full XML error response.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Step 8: Block Public Access (5 min)
&lt;/h3&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; tried to set the bucket ACL to &lt;code&gt;public-read&lt;/code&gt; via API. Error: &lt;code&gt;Put public bucket acl is not allowed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;🧑 I went into OSS Console → Permission Control → found &lt;strong&gt;"Block Public Access: Enabled"&lt;/strong&gt; → disabled it.&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; ran the ACL script again — success.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #6&lt;/strong&gt;: Alibaba Cloud enables &lt;strong&gt;"Block Public Access"&lt;/strong&gt; by default on the account level. This overrides &lt;em&gt;any&lt;/em&gt; bucket-level ACL you set. You must explicitly disable it in the console before public-read works.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  Step 9: DNS Propagation (10 min)
&lt;/h3&gt;

&lt;p&gt;After binding the domain, the URL returned "Could not find IP address." Normal DNS propagation delay — but another step where we both sat and waited, refreshing the browser.&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; kept running &lt;code&gt;nslookup ali.osovsky.com 8.8.8.8&lt;/code&gt; until it resolved. Eventually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ali.osovsky.com → wallplan-deploy.oss-ap-southeast-1.aliyuncs.com → 47.79.50.56
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Step 10: SSL Certificate... Costs $40 💸
&lt;/h3&gt;

&lt;p&gt;The site worked over HTTP. Time for HTTPS.&lt;/p&gt;

&lt;p&gt;🤖 &lt;strong&gt;Antigravity&lt;/strong&gt; installed the Alibaba Cloud Certificate Management SDK, wrote a script, and called the API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: InsufficientQuota — 额度不足
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;🧑 I went to the SSL Certificate Management console. The free certificate package requires a "purchase." I clicked through the flow... &lt;strong&gt;$40/year.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha #7&lt;/strong&gt;: Alibaba Cloud's "free" SSL certificates were quietly transitioned to a $40/year subscription model in February 2026. The free tier no longer exists.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We decided HTTP was fine for an experiment.&lt;/p&gt;




&lt;h3&gt;
  
  
  Final Result
&lt;/h3&gt;

&lt;p&gt;After 1.5 hours, it worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://ali.osovsky.com/wallplan/@yka_yka/index.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calendar loads. The fonts render. It works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scoreboard: Human vs. AI
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Who did what&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Landing page&lt;/td&gt;
&lt;td&gt;🤖 AI designed &amp;amp; built&lt;/td&gt;
&lt;td&gt;✅ Beautiful&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM user + permissions&lt;/td&gt;
&lt;td&gt;🧑 Human (console)&lt;/td&gt;
&lt;td&gt;✅ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OSS activation&lt;/td&gt;
&lt;td&gt;🧑 Human ($0.00 purchase)&lt;/td&gt;
&lt;td&gt;✅ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File upload script&lt;/td&gt;
&lt;td&gt;🤖 AI wrote &amp;amp; ran&lt;/td&gt;
&lt;td&gt;✅ Automated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content-Type fix&lt;/td&gt;
&lt;td&gt;🤖 AI debugged headers&lt;/td&gt;
&lt;td&gt;❌ Wrong diagnosis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom domain needed&lt;/td&gt;
&lt;td&gt;🤖 AI found the real cause&lt;/td&gt;
&lt;td&gt;✅ Researched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS provider lookup&lt;/td&gt;
&lt;td&gt;🤖 AI ran nslookup&lt;/td&gt;
&lt;td&gt;✅ Automated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GoDaddy API script&lt;/td&gt;
&lt;td&gt;🤖 AI wrote it&lt;/td&gt;
&lt;td&gt;❌ API blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNAME record&lt;/td&gt;
&lt;td&gt;🧑 Human (GoDaddy app, 2 AM)&lt;/td&gt;
&lt;td&gt;✅ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain verification script&lt;/td&gt;
&lt;td&gt;🤖 AI wrote &amp;amp; debugged&lt;/td&gt;
&lt;td&gt;✅ Automated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TXT record&lt;/td&gt;
&lt;td&gt;🧑 Human (GoDaddy app)&lt;/td&gt;
&lt;td&gt;✅ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain bind&lt;/td&gt;
&lt;td&gt;🤖 AI ran the script&lt;/td&gt;
&lt;td&gt;✅ Automated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disable Block Public Access&lt;/td&gt;
&lt;td&gt;🧑 Human (console)&lt;/td&gt;
&lt;td&gt;✅ Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set ACL to public-read&lt;/td&gt;
&lt;td&gt;🤖 AI ran the script&lt;/td&gt;
&lt;td&gt;✅ Automated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSL certificate&lt;/td&gt;
&lt;td&gt;🤖 AI wrote SDK script&lt;/td&gt;
&lt;td&gt;❌ $40, abandoned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security hardening (.env)&lt;/td&gt;
&lt;td&gt;🤖 AI refactored all scripts&lt;/td&gt;
&lt;td&gt;✅ Automated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Score: 🤖 AI automated 8 steps. 🧑 Human did 5 console steps manually. 3 steps failed entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI was genuinely useful — it wrote all the scripts, debugged API errors, and parsed XML responses at 2 AM. But it couldn't click "Purchase" in a browser, couldn't bypass GoDaddy's API restrictions, and couldn't buy a $40 SSL certificate without permission.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Hypothesis vs. Reality Gap
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hypothesis&lt;/th&gt;
&lt;th&gt;Reality&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"It's just static files"&lt;/td&gt;
&lt;td&gt;True, but &lt;strong&gt;hosting&lt;/strong&gt; static files has 15 steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 API calls&lt;/td&gt;
&lt;td&gt;5 API calls + 5 manual console steps + 3 failures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complexity: 3–4/10&lt;/td&gt;
&lt;td&gt;Reality: 7/10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30 seconds&lt;/td&gt;
&lt;td&gt;1.5 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero config&lt;/td&gt;
&lt;td&gt;Configure RAM, OSS, ACL, DNS, domain verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fully automated&lt;/td&gt;
&lt;td&gt;5 mandatory manual steps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any cloud provider&lt;/td&gt;
&lt;td&gt;Each provider has unique gotchas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Any DNS provider&lt;/td&gt;
&lt;td&gt;GoDaddy API doesn't even work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free SSL included&lt;/td&gt;
&lt;td&gt;SSL costs $40/year&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The "Automation Ceiling"
&lt;/h3&gt;

&lt;p&gt;Even with an AI assistant writing code in real-time, some steps simply &lt;strong&gt;cannot be automated&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Activating OSS requires a manual purchase flow&lt;/li&gt;
&lt;li&gt;GoDaddy (and some other DNS providers) don't offer working API access&lt;/li&gt;
&lt;li&gt;Domain verification requires adding records in a third-party system&lt;/li&gt;
&lt;li&gt;Block Public Access toggle is an account-level security setting&lt;/li&gt;
&lt;li&gt;SSL certificates require purchasing a subscription plan&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Core Irony
&lt;/h3&gt;

&lt;p&gt;The original hypothesis was elegant and technically correct: &lt;strong&gt;Vercel-compatible projects are just static files, and static files can live anywhere.&lt;/strong&gt; That's true.&lt;/p&gt;

&lt;p&gt;But "deploying static files" turned out to be the &lt;strong&gt;easy 10%&lt;/strong&gt; of the problem. The other 90% is cloud provider onboarding, security policies, DNS fragmentation, and $0.00 purchases with credit cards.&lt;/p&gt;

&lt;h3&gt;
  
  
  DNS: The Universal Problem
&lt;/h3&gt;

&lt;p&gt;The biggest blocker isn't cloud providers — it's &lt;strong&gt;DNS fragmentation&lt;/strong&gt;. Your domain could be on GoDaddy, Cloudflare, Namecheap, Route53, Google Domains, Hetzner, or any of dozens of other providers. Each has its own API (or doesn't have one at all). Supporting all of them is a product in itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  Should DeployBridge Exist as a Product?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Probably not as a SaaS.&lt;/strong&gt; The gap between "one-click deploy" and reality is too wide. Every user's setup is different — different cloud, different DNS, different account configurations.&lt;/p&gt;

&lt;p&gt;But the &lt;strong&gt;article you're reading&lt;/strong&gt; might be more valuable than the product itself. If you're considering building a cross-cloud deployment tool, here's your roadmap of what you'll actually encounter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I'd build instead:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;guided wizard&lt;/strong&gt; that walks users through each step, rather than promising automation&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;CLI tool&lt;/strong&gt; for personal use that handles the specific providers I work with&lt;/li&gt;
&lt;li&gt;A &lt;em&gt;really&lt;/em&gt; good dev.to article about why "one-click" is a lie 😄&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The 8 Gotchas Cheat Sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Gotcha&lt;/th&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Who found it&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;Creating a RAM user ≠ granting permissions&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🧑 Human&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;OSS activation requires a $0.00 "purchase"&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🧑 Human&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Default OSS domain forces file downloads&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🤖 AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;GoDaddy API requires paid plan for DNS access&lt;/td&gt;
&lt;td&gt;GoDaddy&lt;/td&gt;
&lt;td&gt;🤖 AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Domain verification TXT goes to &lt;code&gt;_dnsauth.*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🤖 AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Block Public Access silently overrides ACLs&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🧑 + 🤖&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;"Free" SSL certificates cost $40/year&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🤖 AI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;OSS API signatures need alphabetically sorted params&lt;/td&gt;
&lt;td&gt;Alibaba Cloud&lt;/td&gt;
&lt;td&gt;🤖 AI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;strong&gt;What's the wildest deployment gotcha you've encountered?&lt;/strong&gt; Drop it in the comments 👇&lt;/p&gt;

&lt;p&gt;Follow me: &lt;a href="https://www.linkedin.com/in/osovsky/" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt; · &lt;a href="https://x.com/MaximOsovsky" rel="noopener noreferrer"&gt;X/Twitter&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cloud</category>
      <category>deployment</category>
      <category>alibaba</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
