<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://zhipenghe.me/feed.xml" rel="self" type="application/atom+xml"/><link href="https://zhipenghe.me/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-04-29T10:32:41+10:00</updated><id>https://zhipenghe.me/feed.xml</id><title type="html">Zhipeng &quot;Zippo&quot; He - 何志鹏</title><subtitle>Crafting ideas, solving problems, and exploring passions—welcome to my world. </subtitle><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><entry><title type="html">Code is Cheap. Show me the Prompts</title><link href="https://zhipenghe.me/blog/2025/Code-is-Cheap/" rel="alternate" type="text/html" title="Code is Cheap. Show me the Prompts"/><published>2025-06-26T17:26:00+10:00</published><updated>2025-06-26T17:26:00+10:00</updated><id>https://zhipenghe.me/blog/2025/Code-is-Cheap</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/Code-is-Cheap/"><![CDATA[<h2 id="the-new-currency-of-development">The New Currency of Development</h2> <p>I spent three hours last week debugging a GitHub Actions workflow that kept failing silently. You know the drill—digging through YAML syntax, checking environment variables, cursing at indentation. Then I tried something different: I pasted the broken workflow into Claude Code and said “This GitHub Action isn’t working, fix the deployment script and explain what was wrong.” Two minutes later, I had a working solution and learned that my Docker context was misconfigured.</p> <div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/Code-is-Cheap-480.webp 480w,/assets/img/posts/Code-is-Cheap-800.webp 800w,/assets/img/posts/Code-is-Cheap-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/Code-is-Cheap.png" class="img-fluid rounded z-depth-1 w-100" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> <b>Linus Torvalds' famous quote gets an AI makeover:</b> "Code is cheap. Show me the prompts." </div> <p>The phrase “code is cheap” has never been more accurate, but there’s a twist: the real value now lies in knowing how to communicate with AI. As AI becomes increasingly sophisticated, the bottleneck is no longer writing code—it’s crafting the right prompts to get AI to write it for you.</p> <p>This isn’t just about faster development—it’s about fundamentally different skills. The developers who are thriving now aren’t necessarily the ones who can write the most elegant algorithms or memorize framework APIs. They’re the ones who can break down complex problems into clear, specific instructions that AI can understand and execute. It’s like being a really good technical writer, except your audience is a machine that can code better than most humans but needs explicit guidance on what to build.</p> <h2 id="core-prompting-skills">Core Prompting Skills</h2> <p>Learning to work with AI isn’t rocket science, but it does require unlearning some bad habits. Most developers are used to diving straight into implementation details. With AI, you need to step back and think like a project manager first—define the what, why, and how before asking for code.</p> <p><strong>Context is everything.</strong> The difference between <em>“create a login form”</em> and <em>“create a login form for this Next.js TypeScript project using our existing shadcn/ui components, integrate with our Supabase auth, and match the design system in components/ui/”</em> is the difference between generic boilerplate and code that actually fits your project. I learned this the hard way after getting beautiful React components that used completely different styling libraries than the rest of my app.</p> <p><strong>Specificity saves time.</strong> Vague requests lead to back-and-forth iterations that could have been avoided. Instead of <em>“make this faster”</em>, try <em>“optimize this database query that’s taking 3+ seconds on tables with 100k+ rows, focus on indexing and avoiding N+1 queries.”</em> The AI can’t read your mind, but it can work miracles when you’re explicit about constraints, performance requirements, and edge cases you’re worried about.</p> <p><strong>Think in steps, not solutions.</strong> When tackling complex problems, resist the urge to ask for everything at once. Break it down: <em>“First, analyze this messy legacy code and identify the main architectural issues. Then suggest three refactoring approaches with pros and cons. Finally, implement the safest approach with proper error handling and tests.”</em> This step-by-step approach not only gets better results but also helps you understand the AI’s reasoning process.</p> <p><strong>Ask for the plan before the code.</strong> Before diving into implementation, get the AI to think through the architecture first. Try <em>“Plan out how to add real-time notifications to this app - what components need to change, what new APIs are required, and how should we handle state management?”</em> Once you approve the approach, then ask for the actual code. This prevents those moments where you realize the AI built something elegant but completely wrong for your use case.</p> <hr/> <h2 id="prompt-templates-that-actually-work">Prompt Templates That Actually Work</h2> <p>After months of trial and error, I’ve collected a set of prompt patterns that consistently produce good results. Think of these as your starter templates—modify them for your specific context, but the structure works across different tools and scenarios.</p> <blockquote> <p><strong>Architecture Decisions</strong></p> <p><em>“Design a [system/component] for [specific use case] that needs to handle [requirements like scale, performance, security]. Current tech stack is [languages/frameworks]. Consider [specific constraints like budget, timeline, team skills]. Provide 2-3 approaches with trade-offs and recommend the best option with reasoning.”</em></p> </blockquote> <p>This template works because it forces you to define the problem scope, constraints, and decision criteria upfront. I used this recently for designing a caching layer and got three solid approaches I hadn’t considered, complete with implementation complexity analysis.</p> <blockquote> <p><strong>Code Review</strong></p> <p><em>“Review this [language] code for [specific concerns like security vulnerabilities, performance bottlenecks, maintainability issues]. Focus on [areas of concern]. For each issue found, provide the problematic code snippet, explain why it’s problematic, and show the improved version with explanation.”</em></p> </blockquote> <p>The key here is being specific about what you’re worried about. Generic <em>“review this code”</em> gets generic feedback. But asking for security review of authentication logic gets you detailed analysis of timing attacks, input validation, and session management issues you might miss.</p> <blockquote> <p><strong>Refactoring</strong></p> <p><em>“Modernize this [legacy technology/old pattern] code to use [new framework/modern patterns]. Maintain the same functionality and API contracts. Improve [specific aspects like type safety, error handling, performance]. Show the transformation step-by-step and explain each change.”</em></p> </blockquote> <p>I am using this template to migrate my Jekyll blog components to Next.js, converting Liquid templates to JSX components while preserving the same styling and functionality. The <em>“step-by-step”</em> part is crucial—it helps you understand the changes and makes the refactoring easier to review and test.</p> <blockquote> <p><strong>Test Generation</strong></p> <p><em>“Generate comprehensive tests for this [component/function/API] covering happy path, edge cases, and error conditions. Use [testing framework like Jest, pytest, RSpec]. Include setup/teardown code, mock external dependencies, and test for [specific scenarios like race conditions, invalid inputs, network failures]. Aim for [coverage percentage] code coverage.”</em></p> </blockquote> <p>This template has saved me countless hours of thinking through test scenarios. I recently used it for testing a payment processing function and got tests for edge cases I’d never considered—like what happens when the payment gateway returns a success response but the database write fails.</p> <blockquote> <p><strong>Debugging/Troubleshooting</strong></p> <p><em>“This [language/framework] code is throwing [specific error]. Here’s the error message: [paste error]. Here’s the relevant code: [paste code]. Help me identify the root cause and provide a fix with explanation of why this happened.”</em></p> </blockquote> <p>The magic is in providing both the error message and the actual code. I’ve wasted too many hours getting generic debugging advice because I only shared the error message. When you include the problematic code, AI can spot issues like variable scope problems or async/await misuse that the error message doesn’t make obvious.</p> <blockquote> <p><strong>Performance Optimization</strong></p> <p><em>“This [function/query/component] is performing slowly under [specific conditions]. Current performance is [metrics]. Analyze the bottlenecks and suggest optimizations. Focus on [specific areas like database queries, memory usage, rendering].”</em></p> </blockquote> <p>Including actual performance metrics makes all the difference. Instead of getting generic advice like “use indexes,” you get targeted solutions. When I mentioned my ML model inference was taking 800ms per request, I got specific suggestions about batch processing and model quantization that cut the response time to under 200ms.</p> <blockquote> <p><strong>Documentation Generation</strong></p> <p><em>“Generate comprehensive documentation for this [codebase/module/API]. Include overview, installation steps, usage examples, API reference, and common troubleshooting. Target audience is [developers/end-users]. Follow [documentation style like JSDoc, Sphinx].”</em></p> </blockquote> <p>This template works because it defines the scope and audience upfront. Documentation for end-users is completely different from developer docs. I’ve used this to generate API documentation that was actually usable, not just a wall of technical jargon.</p> <blockquote> <p><strong>API Integration</strong></p> <p><em>“Help me integrate with [API name] to [specific goal]. I need to [specific actions like authenticate, fetch data, handle webhooks]. Handle rate limiting, error responses, and provide proper TypeScript types. Use [HTTP library].”</em></p> </blockquote> <p>The key is being explicit about error handling and types. Generic API integration examples rarely handle real-world issues like rate limiting or webhook verification. This template gets you production-ready code that won’t break when the API returns unexpected responses.</p> <hr/> <h2 id="the-shift-is-real">The Shift is Real</h2> <p>Code is getting cheaper to produce, but knowing how to communicate with AI is becoming more valuable. Master the prompting skills, try the templates, and see what works for your workflow. The tools will keep evolving, but the fundamentals of clear communication with AI will remain.</p> <p>Start small, be specific, and remember: the best prompt is the one that gets you exactly what you need.</p>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="AI"/><category term="WebDev"/><category term="LLM"/><category term="Prompting"/><summary type="html"><![CDATA[Master AI-assisted development with practical prompting techniques and templates that actually work. Learn when AI shines and how to communicate effectively with coding assistants.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/Code-is-Cheap.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/Code-is-Cheap.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Bypassing Image Anti-Hotlinking with Nginx Reverse Proxy</title><link href="https://zhipenghe.me/blog/2025/Bypassing-Image-Anti-Hotlinking-with-Nginx/" rel="alternate" type="text/html" title="Bypassing Image Anti-Hotlinking with Nginx Reverse Proxy"/><published>2025-06-15T16:04:00+10:00</published><updated>2025-06-15T16:04:00+10:00</updated><id>https://zhipenghe.me/blog/2025/Bypassing-Image-Anti-Hotlinking-with-Nginx</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/Bypassing-Image-Anti-Hotlinking-with-Nginx/"><![CDATA[<div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/june-15-pm0404-480.webp 480w,/assets/img/posts/june-15-pm0404-800.webp 800w,/assets/img/posts/june-15-pm0404-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/june-15-pm0404.png" class="img-fluid rounded z-depth-1 w-100" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> <b>15 JUNE PM 04:04</b> </div> <blockquote class="block-danger"> <h5 id="️-usage-warning">⚠️ Usage Warning</h5> <p>This technique should only be used for legitimate aggregation of publicly available RSS content. Do not use it for bypassing paywalls, accessing private content, or commercial redistribution.</p> </blockquote> <p>When setting up my <a href="https://github.com/glanceapp/glance">Glance</a> dashboard to display feeds from various content platforms, I ran into a frustrating problem: images weren’t loading. Instead of the expected thumbnails and cover images that make a dashboard visually engaging, I was seeing broken image placeholders scattered throughout my feeds.</p> <hr/> <h2 id="the-problem">The Problem</h2> <p>Opening the browser’s developer console revealed the culprit immediately. Error messages like this were appearing repeatedly:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>GET https://cdn.example.com/images/photo.jpg 403 (Forbidden)
Failed to load resource: the server responded with a status of 403 (Forbidden)
</code></pre></div></div> <p>These <code class="language-plaintext highlighter-rouge">403 Forbidden</code> errors were blocking external images from RSS feeds, yet the same URLs worked when opened directly in the browser. The image servers’ referrer policy of <code class="language-plaintext highlighter-rouge">strict-origin-when-cross-origin</code> was causing them to reject requests coming from my domain, triggering anti-hotlinking protection.</p> <h3 id="understanding-the-root-cause">Understanding the Root Cause</h3> <p>The problem stemmed from how content platforms protect their images from unauthorized use. Most platforms implement anti-hotlinking protection by checking the <code class="language-plaintext highlighter-rouge">Referer</code> header in image requests. Here’s what was happening:</p> <ol> <li><strong>Referrer checking:</strong> When my dashboard requested an image, the CDN server would examine the <code class="language-plaintext highlighter-rouge">Referer</code> header.</li> <li><strong>Domain mismatch:</strong> Since requests came from my dashboard’s domain rather than the platform’s own website, the server treated them as unauthorized.</li> <li><strong>Inconsistent blocking:</strong> Different CDN subdomains had varying levels of protection, explaining why some images loaded while others didn’t.</li> </ol> <p>This was particularly maddening because the same images would load perfectly when I opened them directly in a new browser tab, but they’d fail when embedded in my dashboard. The RSS feeds themselves parsed correctly with all the text content intact, but without the visual elements, my dashboard looked broken.</p> <hr/> <h2 id="cloudflare-workers-from-perfect-solution-to-dead-end">Cloudflare Workers: From Perfect Solution to Dead End</h2> <p>My first instinct was to reach for Cloudflare Workers—it used to be the perfect solution for this kind of proxy problem. I could write a simple function to intercept image requests, fetch them with the proper headers, and return them with CORS headers enabled. The edge computing model would even provide great performance with global distribution.</p> <p>However, when I checked Cloudflare’s current <a href="https://www.cloudflare.com/en-au/terms/">Terms of Service</a> (last updated December 3, 2024), I discovered a clause in section 2.2.1 that stopped me in my tracks:</p> <blockquote> <p>“(j) use the Services to provide a virtual private network or other similar proxy services.”</p> </blockquote> <p>This restriction effectively rules out using Workers for image proxying, as it would fall under “similar proxy services.” While this clause may have existed before, Cloudflare has been increasingly strict about enforcing it.</p> <p>The risk simply wasn’t worth it. Getting my account suspended would affect not just this dashboard project, but potentially other services I was running on the Cloudflare. I needed a solution that was both effective and compliant with current service terms.</p> <hr/> <h2 id="self-hosted-nginx-reverse-proxy-the-solution">Self-Hosted Nginx Reverse Proxy: The Solution</h2> <p>Since I was already running Glance on my own VPS, I decided to implement a reverse proxy using Nginx on the same server. This approach would give me complete control while avoiding any third-party terms of service violations. The beauty of this solution is that it uses my existing infrastructure without requiring additional hosting costs.</p> <h3 id="setting-up-server-side-caching">Setting Up Server-Side Caching</h3> <p>I added a server-side cache to the Nginx configuration to improve performance and reduce load on the external CDNs. This cache stores images for 14 days (336 hours), ensuring that frequently viewed images load instantly on subsequent visits.</p> <div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># /etc/nginx/nginx.conf</span>
<span class="k">http</span> <span class="p">{</span>
    <span class="c1">##</span>
    <span class="c1"># Cache Settings</span>
    <span class="c1">##</span>
    <span class="kn">proxy_cache_path</span> <span class="n">/var/cache/nginx/images</span>
                     <span class="s">levels=1:2</span>
                     <span class="s">keys_zone=images:10m</span>
                     <span class="s">max_size=1g</span>
                     <span class="s">inactive=336h</span>
                     <span class="s">use_temp_path=off</span><span class="p">;</span>

    <span class="c1"># ... rest of http config</span>
    <span class="kn">include</span> <span class="n">/etc/nginx/sites-enabled/*</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div> <h3 id="configuring-the-image-proxy">Configuring the Image Proxy</h3> <p>Then I configured the image proxy in my site configuration. The key insight was using dynamic DNS resolution to handle multiple CDN providers through a single proxy path. Here’s the configuration I added to my existing server block:</p> <div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># /etc/nginx/sites-available/dashboard.example.com</span>
<span class="k">server</span> <span class="p">{</span>
    <span class="kn">server_name</span> <span class="s">dashboard.example.com</span><span class="p">;</span>

    <span class="c1"># DNS resolver for dynamic proxy</span>
    <span class="kn">resolver</span> <span class="mf">127.0</span><span class="s">.0.53</span> <span class="s">valid=300s</span><span class="p">;</span>
    <span class="kn">resolver_timeout</span> <span class="s">5s</span><span class="p">;</span>

    <span class="c1"># Existing Glance application</span>
    <span class="kn">location</span> <span class="n">/</span> <span class="p">{</span>
        <span class="kn">proxy_pass</span> <span class="s">http://localhost:8080</span><span class="p">;</span>
        <span class="c1"># ... existing headers</span>
    <span class="p">}</span>

    <span class="c1"># Image proxy for external CDNs</span>
    <span class="kn">location</span> <span class="p">~</span> <span class="sr">^/img-proxy/([^/]+)/(.*)$</span> <span class="p">{</span>
        <span class="kn">set</span> <span class="nv">$backend_host</span> <span class="nv">$1</span><span class="p">;</span>
        <span class="kn">set</span> <span class="nv">$backend_path</span> <span class="nv">$2</span><span class="p">;</span>

        <span class="c1"># Enable proxy caching</span>
        <span class="kn">proxy_cache</span> <span class="s">images</span><span class="p">;</span>
        <span class="kn">proxy_cache_valid</span> <span class="mi">200</span> <span class="s">336h</span><span class="p">;</span>
        <span class="kn">proxy_cache_key</span> <span class="s">"</span><span class="nv">$backend_host$backend_path</span><span class="s">"</span><span class="p">;</span>
        <span class="kn">proxy_cache_use_stale</span> <span class="s">error</span> <span class="s">timeout</span> <span class="s">updating</span> <span class="s">http_500</span> <span class="s">http_502</span> <span class="s">http_503</span> <span class="s">http_504</span><span class="p">;</span>

        <span class="c1"># Proxy to external CDN</span>
        <span class="kn">proxy_pass</span> <span class="s">https://</span><span class="nv">$backend_host</span><span class="n">/</span><span class="nv">$backend_path</span><span class="p">;</span>

        <span class="c1"># Essential headers for bypassing anti-hotlinking</span>
        <span class="kn">proxy_set_header</span> <span class="s">Referer</span> <span class="s">"https://</span><span class="nv">$backend_host</span><span class="n">/"</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">User-Agent</span> <span class="s">"Mozilla/5.0</span> <span class="s">(Windows</span> <span class="s">NT</span> <span class="mf">10.0</span><span class="p">;</span> <span class="kn">Win64</span><span class="p">;</span> <span class="kn">x64</span><span class="s">)</span> <span class="s">AppleWebKit/537.36"</span><span class="p">;</span>
        <span class="kn">proxy_set_header</span> <span class="s">Host</span> <span class="nv">$backend_host</span><span class="p">;</span>

        <span class="c1"># SSL configuration</span>
        <span class="kn">proxy_ssl_verify</span> <span class="no">off</span><span class="p">;</span>
        <span class="kn">proxy_ssl_server_name</span> <span class="no">on</span><span class="p">;</span>

        <span class="c1"># Add CORS and cache headers</span>
        <span class="kn">add_header</span> <span class="s">Access-Control-Allow-Origin</span> <span class="s">"*"</span> <span class="s">always</span><span class="p">;</span>
        <span class="kn">add_header</span> <span class="s">Cache-Control</span> <span class="s">"public,</span> <span class="s">max-age=1209600"</span> <span class="s">always</span><span class="p">;</span>
        <span class="kn">add_header</span> <span class="s">X-Cache-Status</span> <span class="nv">$upstream_cache_status</span> <span class="s">always</span><span class="p">;</span>

        <span class="c1"># Basic protection against abuse</span>
        <span class="kn">valid_referers</span> <span class="s">none</span> <span class="s">blocked</span> <span class="s">dashboard.example.com</span><span class="p">;</span>
        <span class="kn">if</span> <span class="s">(</span><span class="nv">$invalid_referer</span><span class="s">)</span> <span class="p">{</span>
            <span class="kn">return</span> <span class="mi">403</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c1"># ... existing SSL configuration</span>
<span class="p">}</span>
</code></pre></div></div> <p>The magic happens in the regex location block. By capturing the hostname and path in separate variables, I could support any external CDN domain through my proxy. The URL transformation is clean:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Original:  https://cdn.platform.com/images/photo.jpg
Proxied:   https://dashboard.example.com/img-proxy/cdn.platform.com/images/photo.jpg
</code></pre></div></div> <p>After setting up the configuration, I created the cache directory:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo mkdir</span> <span class="nt">-p</span> /var/cache/nginx/images
<span class="nb">sudo chown</span> <span class="nt">-R</span> www-data:www-data /var/cache/nginx/images
<span class="nb">sudo chmod</span> <span class="nt">-R</span> 755 /var/cache/nginx/images
<span class="nb">sudo </span>nginx <span class="nt">-t</span> <span class="o">&amp;&amp;</span> <span class="nb">sudo </span>systemctl reload nginx
</code></pre></div></div> <h3 id="automating-image-url-conversion">Automating Image URL Conversion</h3> <p>With the Nginx proxy in place, I needed a way to automatically convert external image URLs in my Glance dashboard. Rather than manually updating every RSS feed configuration, I wrote a client-side script that would handle the conversion transparently.</p> <p>I added this JavaScript to my Glance configuration as a custom <code class="language-plaintext highlighter-rouge">.js</code> file:</p> <div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
  <span class="c1">// CDN domains that commonly implement anti-hotlinking</span>
  <span class="kd">const</span> <span class="nx">BLOCKED_DOMAINS</span> <span class="o">=</span> <span class="p">[</span><span class="dl">"</span><span class="s2">cdn.example.com</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">images.platform.com</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">static.service.net</span><span class="dl">"</span><span class="p">];</span>

  <span class="kd">function</span> <span class="nf">convertExternalImages</span><span class="p">()</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">selector</span> <span class="o">=</span> <span class="nx">BLOCKED_DOMAINS</span><span class="p">.</span><span class="nf">map</span><span class="p">((</span><span class="nx">domain</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="s2">`img[src*="</span><span class="p">${</span><span class="nx">domain</span><span class="p">}</span><span class="s2">"]:not([data-converted])`</span><span class="p">).</span><span class="nf">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">,</span><span class="dl">"</span><span class="p">);</span>

    <span class="nb">document</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="nx">selector</span><span class="p">).</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">img</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
      <span class="k">try</span> <span class="p">{</span>
        <span class="kd">const</span> <span class="nx">originalSrc</span> <span class="o">=</span> <span class="nx">img</span><span class="p">.</span><span class="nx">src</span><span class="p">;</span>
        <span class="kd">const</span> <span class="nx">proxySrc</span> <span class="o">=</span> <span class="nx">originalSrc</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/https</span><span class="se">?</span><span class="sr">:</span><span class="se">\/\/([^/]</span><span class="sr">+</span><span class="se">)\/</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">"</span><span class="s2">/img-proxy/$1/</span><span class="dl">"</span><span class="p">);</span>

        <span class="nx">img</span><span class="p">.</span><span class="nx">src</span> <span class="o">=</span> <span class="nx">proxySrc</span><span class="p">;</span>
        <span class="nx">img</span><span class="p">.</span><span class="nf">setAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">data-converted</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">true</span><span class="dl">"</span><span class="p">);</span>
        <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">Converting:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">originalSrc</span><span class="p">,</span> <span class="dl">"</span><span class="s2">→</span><span class="dl">"</span><span class="p">,</span> <span class="nx">proxySrc</span><span class="p">);</span>
      <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
        <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Failed to convert image:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
      <span class="p">}</span>
    <span class="p">});</span>
  <span class="p">}</span>

  <span class="k">if </span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">readyState</span> <span class="o">!==</span> <span class="dl">"</span><span class="s2">loading</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
    <span class="nf">convertExternalImages</span><span class="p">();</span>
  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
    <span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">DOMContentLoaded</span><span class="dl">"</span><span class="p">,</span> <span class="nx">convertExternalImages</span><span class="p">);</span>
  <span class="p">}</span>

  <span class="c1">// Check for new images every 3 seconds</span>
  <span class="nf">setInterval</span><span class="p">(</span><span class="nx">convertExternalImages</span><span class="p">,</span> <span class="mi">3000</span><span class="p">);</span>
<span class="p">})();</span>
</code></pre></div></div> <p>The script maintains a list of problematic CDN domains and only processes images from those sources. When it detects a blocked image, it uses regex to rebuild the URL to route through my proxy. This focused approach avoids wasting resources on images that already load fine.</p> <h3 id="results-and-testing">Results and Testing</h3> <p>After implementing both components, I tested the setup:</p> <div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Test in browser console</span>
<span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/img-proxy/cdn.platform.com/images/test.jpg</span><span class="dl">"</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">then</span><span class="p">((</span><span class="nx">response</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">Status:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">statusText</span><span class="p">))</span>
  <span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">"</span><span class="s2">Error:</span><span class="dl">"</span><span class="p">,</span> <span class="nx">error</span><span class="p">));</span>
</code></pre></div></div> <p>The test returned <code class="language-plaintext highlighter-rouge">Status: 200 OK</code>, confirming the proxy was working correctly with proper CORS headers.</p> <p>With the proxy confirmed working, the dashboard transformation was impressive. Images that had been broken placeholders now loaded correctly throughout my feeds. RSS content became visually rich with thumbnails and cover images that made the dashboard much more engaging.</p> <p>The JavaScript automatically converts problematic image URLs as new RSS content loads. When fresh content appears, any images from blocked domains are quickly converted to use the proxy route. The 14-day cache configuration means once an image loads through the proxy, subsequent views are instant.</p> <h3 id="verifying-cache-performance">Verifying Cache Performance</h3> <p>To verify that the caching was working as intended, I checked the Nginx cache directory:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Count cached files</span>
<span class="nb">sudo </span>find /var/cache/nginx/images <span class="nt">-type</span> f | <span class="nb">wc</span> <span class="nt">-l</span>

<span class="c"># Check cache size</span>
<span class="nb">sudo du</span> <span class="nt">-sh</span> /var/cache/nginx/images
</code></pre></div></div> <p>The cache was working as intended, with files being created and deleted as expected.</p> <hr/> <h2 id="responsible-usage">Responsible Usage</h2> <p>This solution requires thoughtful implementation to respect both technical constraints and content platforms. The 14-day caching configuration significantly reduces load on source CDNs—an image that might be viewed dozens of times only generates a single upstream request.</p> <p>The referrer validation prevents unauthorized access. The <code class="language-plaintext highlighter-rouge">valid_referers</code> directive restricts proxy usage to requests originating from my dashboard domain, ensuring it can’t be exploited as an open relay by external users.</p> <p>When implementing similar solutions, consider the scope and purpose of your usage to maintain ethical standards and avoid potential service violations.</p>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="WebDev"/><category term="Self-hosted"/><category term="Linux"/><category term="Nginx"/><category term="Glance"/><category term="Proxy"/><summary type="html"><![CDATA[A guide on implementing an Nginx reverse proxy to solve image hotlinking issues in RSS feeds. Learn how to bypass anti-hotlinking protection while maintaining ethical usage, including server-side caching, dynamic DNS resolution, and automated URL conversion techniques.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/june-15-pm0404.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/june-15-pm0404.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Jekyll LiveReload vs WebSocket Secure: A Protocol Compatibility Issue</title><link href="https://zhipenghe.me/blog/2025/Jekyll-LiveReload-vs-WebSocket-Secure/" rel="alternate" type="text/html" title="Jekyll LiveReload vs WebSocket Secure: A Protocol Compatibility Issue"/><published>2025-06-12T02:37:38+10:00</published><updated>2025-06-12T02:37:38+10:00</updated><id>https://zhipenghe.me/blog/2025/Jekyll-LiveReload-vs-WebSocket-Secure</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/Jekyll-LiveReload-vs-WebSocket-Secure/"><![CDATA[<h2 id="the-problem">The Problem</h2> <div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/OrbStack-Setting-Domain-480.webp 480w,/assets/img/posts/OrbStack-Setting-Domain-800.webp 800w,/assets/img/posts/OrbStack-Setting-Domain-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/OrbStack-Setting-Domain.png" class="img-fluid rounded z-depth-1 w-100" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> I have to disable "Allow access to container domains &amp; IPs" in OrbStack to make LiveReload work for Jekyll. </div> <p>I recently switched from Docker Desktop to OrbStack because of its better performance and native macOS integration. One of OrbStack’s convenient features is automatic domain name assignment for containers. Instead of remembering port numbers, containers get clean URLs like <code class="language-plaintext highlighter-rouge">https://container-name.orb.local</code> with automatic HTTPS certificates. This makes accessing development services much more elegant than dealing with <code class="language-plaintext highlighter-rouge">localhost:3000</code>, <code class="language-plaintext highlighter-rouge">localhost:4000</code>, etc.</p> <p>However, when I maintain my Jekyll-based website in a container, this convenient feature introduced an unexpected issue with LiveReload that took some detective work to understand.</p> <p>When I try to use the OrbStack generated domain name <code class="language-plaintext highlighter-rouge">https://jekyll.zhipenghegithubio.orb.local/</code> to access my Jekyll site, LiveReload crashes immediately with the following error:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>jekyll-1  | LiveReload experienced an error. Run with --trace for more information.
jekyll-1  | /usr/local/bundle/gems/jekyll-4.4.1/lib/jekyll/commands/serve/websockets.rb:44:in 'HTTP::Parser#&lt;&lt;': Could not parse data entirely (0 != 247) (HTTP::Parser::Error)
jekyll-1  | 	from /usr/local/bundle/gems/jekyll-4.4.1/lib/jekyll/commands/serve/websockets.rb:44:in 'Jekyll::Commands::Serve::HttpAwareConnection#dispatch'
jekyll-1  | 	from /usr/local/bundle/gems/em-websocket-0.5.3/lib/em-websocket/connection.rb:79:in 'EventMachine::WebSocket::Connection#receive_data'
jekyll-1  | 	from /usr/local/bundle/gems/eventmachine-1.2.7/lib/eventmachine.rb:195:in 'EventMachine.run_machine'
jekyll-1  | 	from /usr/local/bundle/gems/eventmachine-1.2.7/lib/eventmachine.rb:195:in 'EventMachine.run'
jekyll-1  | 	from /usr/local/bundle/gems/jekyll-4.4.1/lib/jekyll/commands/serve/live_reload_reactor.rb:42:in 'block in Jekyll::Commands::Serve::LiveReloadReactor#start'
</code></pre></div></div> <p>If revisiting the page, it will show <code class="language-plaintext highlighter-rouge">502 Bad Gateway</code> error and <code class="language-plaintext highlighter-rouge">OrbStack proxy error: dial tcp 192.168.97.2:8080: connect: connection refused</code>.</p> <p>The WebSocket parsing error doesn’t just break LiveReload—it crashes the entire Jekyll server. This means the site becomes completely inaccessible until the container is restarted. Here’s what I discovered after systematically testing different configurations.</p> <hr/> <h2 id="testing-with-another-websocket-application">Testing with Another WebSocket Application</h2> <p>Facing this crash, I needed to understand what was causing it. Was this an issue with OrbStack’s reverse proxy, Jekyll’s implementation, or something else entirely? To isolate the problem, I decided to test whether other WebSocket applications had the same issue in the identical setup.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker run <span class="nt">-p</span> 8080:8080 jmalloc/echo-server
</code></pre></div></div> <p>I then tested both access methods:</p> <ul> <li>Direct access: <code class="language-plaintext highlighter-rouge">http://localhost:8080/.ws</code></li> <li>OrbStack domain: <code class="language-plaintext highlighter-rouge">https://condescending_solomon.orb.local/.ws</code> (The container name <code class="language-plaintext highlighter-rouge">condescending_solomon</code> is automatically generated)</li> </ul> <p><strong>Result:</strong> Both worked perfectly! The WebSocket echo server had no problems connecting and echoing messages through either direct access or OrbStack’s domain routing.</p> <p>Looking at the browser’s network tab revealed the difference in WebSocket connection types:</p> <ul> <li>Direct: <code class="language-plaintext highlighter-rouge">ws://localhost:8080/</code> (plain WebSocket)</li> <li>OrbStack: <code class="language-plaintext highlighter-rouge">wss://condescending_solomon.orb.local/</code> (WebSocket Secure)</li> </ul> <p>Both WebSocket connections established successfully and functioned normally. The echo server handled both WS and WSS connections gracefully. <strong>This proved that OrbStack’s reverse proxy handles WebSockets correctly</strong> - the issue was specific to Jekyll, not OrbStack.</p> <hr/> <h2 id="the-real-issue-jekylls-websocket-limitation">The Real Issue: Jekyll’s WebSocket Limitation</h2> <p>With OrbStack ruled out as the culprit, I investigated Jekyll-specific WebSocket issues and found this isn’t an isolated problem. <a href="https://github.com/jekyll/jekyll/issues/9495">Jekyll GitHub issue #9495</a> documents the exact same error occurring when Jekyll runs behind reverse proxies like Caddy.</p> <p>The issue report contains a crucial finding from direct testing of Jekyll’s WebSocket behavior:</p> <ul> <li><strong>Plain WebSocket (<code class="language-plaintext highlighter-rouge">ws://</code>) connections work fine</strong></li> <li><strong>Secure WebSocket (<code class="language-plaintext highlighter-rouge">wss://</code>) connections crash Jekyll</strong></li> </ul> <p>The report explains: <em>“The Jekyll LiveReload barfs when it receives the encrypted data, because it can’t parse it.”</em> Most importantly, the investigation proved this was caused by a specific Jekyll change: <em>“I can confirm that [reverting PR #8718] fixes this issue; LiveReload works as expected even over reverse-proxied HTTPS URLs.”</em> This definitively links the problem to Jekyll 4.3.2’s protocol detection change.</p> <h3 id="understanding-jekylls-change-in-pr-8718">Understanding Jekyll’s Change in PR #8718</h3> <p>The issue stems from a change in Jekyll 4.3.2 (<a href="https://github.com/jekyll/jekyll/pull/8718">PR #8718</a>) that modified how LiveReload script injection works.</p> <p><strong>Before Jekyll 4.3.2:</strong></p> <ul> <li>Jekyll always used hardcoded <code class="language-plaintext highlighter-rouge">http://</code> for LiveReload connections</li> <li>Even HTTPS pages made plain WebSocket (<code class="language-plaintext highlighter-rouge">ws://</code>) connections to LiveReload</li> <li>This worked fine with reverse proxies</li> </ul> <p><strong>After Jekyll 4.3.2:</strong></p> <ul> <li>Jekyll switched to using <code class="language-plaintext highlighter-rouge">location.protocol</code> to dynamically detect the page protocol</li> <li>HTTPS pages now attempt WebSocket Secure (<code class="language-plaintext highlighter-rouge">wss://</code>) connections to LiveReload</li> <li>This breaks Jekyll’s WebSocket server</li> </ul> <p>Here is the diff of the change in the <a href="https://github.com/jekyll/jekyll/pull/8718">PR #8718</a>:</p> <pre><code class="language-diff2html">
diff --git a/lib/jekyll/commands/serve/servlet.rb b/lib/jekyll/commands/serve/servlet.rb
index 2544616adf3..cee9c663a8a 100644
--- a/lib/jekyll/commands/serve/servlet.rb
+++ b/lib/jekyll/commands/serve/servlet.rb
@@ -101,7 +101,7 @@ def template
           @template ||= ERB.new(&lt;&lt;~TEMPLATE)
             &lt;script&gt;
               document.write(
-                '&lt;script src="http://' +
+                '&lt;script src="' + location.protocol + '//' +
                 (location.host || 'localhost').split(':')[0] +
                 ':&lt;%=@options["livereload_port"] %&gt;/livereload.js?snipver=1&lt;%= livereload_args %&gt;"' +
                 '&gt;&lt;/' +
</code></pre> <p>While this seemed like a logical improvement for HTTPS sites, it revealed that Jekyll’s WebSocket implementation has a fundamental limitation: it cannot handle WSS connections.</p> <p>Other WebSocket implementations (like the echo server) handle both WS and WSS connections gracefully, but Jekyll’s LiveReload server is limited to plain WebSocket connections only.</p> <hr/> <h2 id="the-architecture-problem">The Architecture Problem</h2> <p>The incompatibility creates a fundamental mismatch in modern HTTPS development environments:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Browser (HTTPS page) → wants WSS connection
     ↓
Jekyll LiveReload → only supports WS connection
     ↓
= Protocol incompatibility → Crash
</code></pre></div></div> <p>When accessing Jekyll through any HTTPS reverse proxy:</p> <ol> <li><strong>Browser sees HTTPS page</strong> → uses <code class="language-plaintext highlighter-rouge">location.protocol</code> to determine WebSocket protocol</li> <li><strong>Browser attempts WSS connection</strong> to match the secure page protocol</li> <li><strong>Jekyll’s EventMachine parser receives encrypted data</strong> but can only handle plain text</li> <li><strong>Parser crashes</strong> trying to interpret encrypted frames as plain WebSocket data</li> </ol> <h3 id="impact-on-development-environments">Impact on Development Environments</h3> <p>This limitation affects Jekyll’s compatibility with modern development setups:</p> <ul> <li><strong>Any HTTPS reverse proxy</strong> (OrbStack, Caddy, nginx, Traefik)</li> <li><strong>Container orchestration</strong> (Docker Compose with HTTPS, Kubernetes ingress)</li> <li><strong>Development tools</strong> that provide automatic HTTPS (like OrbStack’s domains)</li> <li><strong>Production-like environments</strong> where HTTPS is standard</li> </ul> <p>Jekyll 4.3.2+ is fundamentally incompatible with HTTPS development environments that use LiveReload. The previous hardcoded <code class="language-plaintext highlighter-rouge">http://</code> approach accidentally worked around this limitation by forcing plain WebSocket connections even from HTTPS pages.</p> <h2 id="time-to-move-on-from-jekyll">Time to Move On from Jekyll?</h2> <p>Jekyll’s popularity has been steadily declining as modern static site generators like Astro, Next.js, and Nuxt offer better developer experiences and more robust tooling. This WebSocket limitation is just another nail in the coffin—when your development server can’t handle basic HTTPS environments that have been standard for years, it’s a pretty clear sign the project is falling behind.</p> <p>The choice between useful development features (like OrbStack’s convenient .orb.local domains) and Jekyll compatibility shouldn’t even be a question. Modern tools should adapt to modern workflows, not force developers to work around decade-old limitations.</p> <p>At some point, you have to ask: is it worth sticking with a static site generator that breaks when you try to use it with contemporary development practices? Jekyll had its moment, but that moment was 2014. Maybe it’s time to migrate my website to something that actually works with today’s containerized, HTTPS-first development environments.</p>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="WebDev"/><category term="Jekyll"/><category term="OrbStack"/><category term="Docker"/><category term="WebSocket"/><category term="macOS"/><summary type="html"><![CDATA[Jekyll's LiveReload breaks with HTTPS reverse proxies in OrbStack due to WebSocket limitations. Testing with other tools proves it's Jekyll's problem, not the proxy's.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/OrbStack-Setting-Domain.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/OrbStack-Setting-Domain.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Docker API Exposure via Tailscale VPN - Windows Setup Guide (with WSL2 Backend)</title><link href="https://zhipenghe.me/blog/2025/Docker-API-Exposure-Windows/" rel="alternate" type="text/html" title="Docker API Exposure via Tailscale VPN - Windows Setup Guide (with WSL2 Backend)"/><published>2025-06-09T22:37:00+10:00</published><updated>2025-06-09T22:37:00+10:00</updated><id>https://zhipenghe.me/blog/2025/Docker-API-Exposure-Windows</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/Docker-API-Exposure-Windows/"><![CDATA[<blockquote> <p>In <a href="/blog/2025/Docker-API-Exposure-Linux">last post</a>, I’ve shown how to expose Docker’s API (port 2376) to your Tailscale VPN network on Linux systems. This post is a continuation of that guide, but for Windows with WSL2 backend.</p> </blockquote> <h2 id="overview">Overview</h2> <p>This guide shows how to expose Docker’s API (port 2375) to your Tailscale VPN network on Windows with WSL2 backend. It’s a simple guide for those who want to use Docker remotely or access the status of Docker Containers from another device.</p> <blockquote class="block-warning"> <h5 id="warning">WARNING</h5> <p>We’re using port 2375 <strong>without SSL/TLS encryption</strong>. Thus, you need to use a VPN tunnel to provide encryption and access control.</p> </blockquote> <div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/Docker-Desktop-Setting-Windows-480.webp 480w,/assets/img/posts/Docker-Desktop-Setting-Windows-800.webp 800w,/assets/img/posts/Docker-Desktop-Setting-Windows-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/Docker-Desktop-Setting-Windows.png" class="img-fluid rounded z-depth-1 w-100" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> You can manually expose port 2375 to `localhost` in the settings of Docker Desktop for Windows. </div> <hr/> <h2 id="prerequisites">Prerequisites</h2> <ul> <li>Docker Desktop for Windows with WSL2 backend</li> <li>Tailscale VPN installed and running</li> <li>Administrator access to Windows</li> </ul> <hr/> <h2 id="step-1-enable-docker-api-in-docker-desktop">Step 1: Enable Docker API in Docker Desktop</h2> <ol> <li>Open Docker Desktop</li> <li>Go to <strong>Settings → General</strong></li> <li>Check <strong>“Expose daemon on tcp://localhost:2375 without TLS”</strong></li> <li>Click <strong>Apply &amp; Restart</strong></li> </ol> <hr/> <h2 id="step-2-find-your-tailscale-ip-address">Step 2: Find Your Tailscale IP Address</h2> <div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">ipconfig</span>
</code></pre></div></div> <p>Look for the <strong>Tailscale adapter</strong> - note the IPv4 address (e.g., <code class="language-plaintext highlighter-rouge">100.xxx.xxx.xxx</code>)</p> <hr/> <h2 id="step-3-set-up-port-forwarding-run-as-administrator">Step 3: Set Up Port Forwarding (Run as Administrator)</h2> <h3 id="open-powershell-as-administrator">Open PowerShell as Administrator:</h3> <ul> <li>Press <code class="language-plaintext highlighter-rouge">Win + X</code> → Select “Windows PowerShell (Admin)”</li> <li>Or press <code class="language-plaintext highlighter-rouge">Win + R</code> → type <code class="language-plaintext highlighter-rouge">powershell</code> → press <code class="language-plaintext highlighter-rouge">Ctrl + Shift + Enter</code></li> </ul> <h3 id="run-these-commands">Run these commands:</h3> <div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">:: Add port forwarding rule (replace with your Tailscale IP)</span>
<span class="nb">netsh</span> <span class="kd">interface</span> <span class="kd">portproxy</span> <span class="kd">add</span> <span class="kd">v4tov4</span> <span class="kd">listenport</span><span class="o">=</span><span class="m">2375</span> <span class="kd">listenaddress</span><span class="o">=</span><span class="m">100</span>.xxx.xxx.xxx <span class="kd">connectport</span><span class="o">=</span><span class="m">2375</span> <span class="kd">connectaddress</span><span class="o">=</span><span class="m">127</span>.0.0.1

<span class="c">:: Configure Windows Firewall (Optional but recommended)</span>
<span class="nb">netsh</span> <span class="kd">advfirewall</span> <span class="kd">firewall</span> <span class="kd">add</span> <span class="kd">rule</span> <span class="kd">name</span><span class="o">=</span><span class="s2">"Docker API Tailscale"</span> <span class="nb">dir</span><span class="o">=</span><span class="k">in</span> <span class="kd">action</span><span class="o">=</span><span class="kd">allow</span> <span class="kd">protocol</span><span class="o">=</span><span class="kd">TCP</span> <span class="kd">localport</span><span class="o">=</span><span class="m">2375</span>
</code></pre></div></div> <hr/> <h2 id="step-4-verify-setup">Step 4: Verify Setup</h2> <div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">:: Check port forwarding rules</span>
<span class="nb">netsh</span> <span class="kd">interface</span> <span class="kd">portproxy</span> <span class="kd">show</span> <span class="kd">all</span>
</code></pre></div></div> <p>Expected output:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Listen on ipv4:             Connect to ipv4:
Address         Port        Address         Port
--------------- ----------  --------------- ----------
100.xxx.xxx.xxx 2375        127.0.0.1       2375
</code></pre></div></div> <hr/> <h2 id="step-5-test-connection">Step 5: Test Connection</h2> <p>From any device on your Tailscale network:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test Docker connection</span>
docker <span class="nt">-H</span> tcp://100.xxx.xxx.xxx:2375 version

<span class="c"># Or test port connectivity</span>
telnet 100.xxx.xxx.xxx 2375
</code></pre></div></div> <hr/> <h2 id="cleanup-commands-if-needed">Cleanup Commands (if needed)</h2> <div class="language-bat highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">:: Remove port forwarding rule</span>
<span class="nb">netsh</span> <span class="kd">interface</span> <span class="kd">portproxy</span> <span class="kd">delete</span> <span class="kd">v4tov4</span> <span class="kd">listenport</span><span class="o">=</span><span class="m">2375</span> <span class="kd">listenaddress</span><span class="o">=</span><span class="m">100</span>.xxx.xxx.xxx

<span class="c">:: Remove firewall rule</span>
<span class="nb">netsh</span> <span class="kd">advfirewall</span> <span class="kd">firewall</span> <span class="kd">delete</span> <span class="kd">rule</span> <span class="kd">name</span><span class="o">=</span><span class="s2">"Docker API Tailscale"</span>
</code></pre></div></div> <hr/> <h2 id="security-notes">Security Notes</h2> <ul> <li>⚠️ <strong>Warning</strong>: This exposes Docker daemon without TLS encryption</li> <li>✅ <strong>Safe</strong>: Tailscale provides encrypted VPN tunnel</li> <li>🔒 <strong>Access</strong>: Only devices on your Tailscale network can connect</li> <li>💡 <strong>Tip</strong>: Tailscale IPs are usually stable and don’t change frequently</li> </ul> <hr/> <h2 id="usage-examples">Usage Examples</h2> <p>Once configured, you can use Docker remotely:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Set environment variable for easier use</span>
<span class="nb">export </span><span class="nv">DOCKER_HOST</span><span class="o">=</span>tcp://100.xxx.xxx.xxx:2375

<span class="c"># Now use Docker commands normally</span>
docker ps
docker images
docker run hello-world
</code></pre></div></div> <hr/> <h2 id="troubleshooting">Troubleshooting</h2> <ul> <li><strong>Connection refused</strong>: Check if Docker Desktop is running</li> <li><strong>Port not accessible</strong>: Verify firewall rules and port forwarding</li> <li><strong>Permission denied</strong>: Ensure commands were run as Administrator</li> <li><strong>Tailscale IP changed</strong>: Update port forwarding rule with new IP</li> </ul>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="Self-hosted"/><category term="Docker"/><category term="Tailscale"/><category term="Proxy"/><category term="Windows"/><category term="WSL2"/><summary type="html"><![CDATA[This guide shows how to expose Docker's API (port 2375) to your Tailscale VPN network on Windows with WSL2 backend.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/Docker-Desktop-Setting-Windows.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/Docker-Desktop-Setting-Windows.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Docker API Exposure via Tailscale VPN - Linux Setup Guide</title><link href="https://zhipenghe.me/blog/2025/Docker-API-Exposure-Linux/" rel="alternate" type="text/html" title="Docker API Exposure via Tailscale VPN - Linux Setup Guide"/><published>2025-06-07T22:37:00+10:00</published><updated>2025-06-07T22:37:00+10:00</updated><id>https://zhipenghe.me/blog/2025/Docker-API-Exposure-Linux</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/Docker-API-Exposure-Linux/"><![CDATA[<h2 id="overview">Overview</h2> <p>This guide shows how to expose Docker’s API (port 2376) to your Tailscale VPN network on Linux systems. It’s a simple guide for those who want to use Docker remotely or access the status of Docker Containers from another device.</p> <blockquote class="block-warning"> <h5 id="warning">WARNING</h5> <p>We’re using port 2376 but <strong>without SSL/TLS encryption</strong>. Thus, you need to use a VPN tunnel to provide encryption and access control.</p> </blockquote> <div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/Docker-API-Exposure-Linux-480.webp 480w,/assets/img/posts/Docker-API-Exposure-Linux-800.webp 800w,/assets/img/posts/Docker-API-Exposure-Linux-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/Docker-API-Exposure-Linux.png" class="img-fluid rounded z-depth-1 w-100" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> This is a workaround, but it's not a good idea to expose Docker API to the public internet. </div> <hr/> <h2 id="prerequisites">Prerequisites</h2> <ul> <li>Docker installed and running on Linux</li> <li>Tailscale VPN installed and connected</li> <li>sudo/root access to the system</li> </ul> <hr/> <h2 id="step-1-find-your-tailscale-ip-address">Step 1: Find Your Tailscale IP Address</h2> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Get your Tailscale IP</span>
tailscale ip <span class="nt">-4</span>
</code></pre></div></div> <p>Note the IP address (e.g., <code class="language-plaintext highlighter-rouge">100.xxx.xxx.xxx</code>) - you’ll need this later.</p> <hr/> <h2 id="step-2-configure-docker-daemon">Step 2: Configure Docker Daemon</h2> <h3 id="using-systemd-override-recommended">Using systemd Override (Recommended)</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Create systemd override</span>
<span class="nb">sudo </span>systemctl edit docker.service
</code></pre></div></div> <p>Add this configuration (replace <code class="language-plaintext highlighter-rouge">100.xxx.xxx.xxx</code> with your actual Tailscale IP):</p> <div class="language-ini highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">[Service]</span><span class="w">
</span><span class="py">ExecStart</span><span class="p">=</span>
<span class="py">ExecStart</span><span class="p">=</span><span class="s">/usr/bin/dockerd -H unix:///var/run/docker.sock -H tcp://100.xxx.xxx.xxx:2376 --containerd=/run/containerd/containerd.sock</span>
</code></pre></div></div> <hr/> <h2 id="step-3-apply-configuration">Step 3: Apply Configuration</h2> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Reload systemd and restart Docker</span>
<span class="nb">sudo </span>systemctl daemon-reload
<span class="nb">sudo </span>systemctl restart docker

<span class="c"># Verify Docker is running</span>
<span class="nb">sudo </span>systemctl status docker
</code></pre></div></div> <hr/> <h2 id="step-4-configure-firewall">Step 4: Configure Firewall</h2> <h3 id="for-ufw-ubuntudebian">For UFW (Ubuntu/Debian):</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install UFW if not present</span>
<span class="nb">sudo </span>apt <span class="nb">install </span>ufw <span class="nt">-y</span>

<span class="c"># Allow SSH (important - don't lock yourself out!)</span>
<span class="nb">sudo </span>ufw allow ssh

<span class="c"># Allow existing services</span>
<span class="nb">sudo </span>ufw allow 80
<span class="nb">sudo </span>ufw allow 443

<span class="c"># Allow Docker API from Tailscale network</span>
<span class="nb">sudo </span>ufw allow from 100.64.0.0/10 to any port 2376

<span class="c"># Enable UFW</span>
<span class="nb">sudo </span>ufw <span class="nb">enable</span>

<span class="c"># Check status</span>
<span class="nb">sudo </span>ufw status numbered
</code></pre></div></div> <h3 id="for-iptables-direct-method">For iptables (Direct method):</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Add rule to allow Docker API from Tailscale network</span>
<span class="nb">sudo </span>iptables <span class="nt">-I</span> INPUT <span class="nt">-p</span> tcp <span class="nt">--dport</span> 2376 <span class="nt">-s</span> 100.64.0.0/10 <span class="nt">-j</span> ACCEPT

<span class="c"># Save rules (method varies by distribution)</span>
<span class="c"># Ubuntu/Debian:</span>
<span class="nb">sudo </span>iptables-save <span class="o">&gt;</span> /etc/iptables/rules.v4

<span class="c"># CentOS/RHEL:</span>
<span class="nb">sudo </span>service iptables save
</code></pre></div></div> <hr/> <h2 id="step-5-verify-setup">Step 5: Verify Setup</h2> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check if Docker is listening on the correct port</span>
<span class="nb">sudo </span>ss <span class="nt">-tlnp</span> | <span class="nb">grep</span> :2376

<span class="c"># Check your Tailscale IP</span>
tailscale ip <span class="nt">-4</span>

<span class="c"># Test local connection</span>
curl http://<span class="si">$(</span>tailscale ip <span class="nt">-4</span><span class="si">)</span>:2376/containers/json
</code></pre></div></div> <p>Expected output should show JSON with container information.</p> <hr/> <h2 id="step-6-test-from-remote-device">Step 6: Test from Remote Device</h2> <p>From another device on your Tailscale network:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Test Docker connection (replace with your Tailscale IP)</span>
curl http://100.xxx.xxx.xxx:2376/containers/json

<span class="c"># Or test with Docker client</span>
docker <span class="nt">-H</span> tcp://100.xxx.xxx.xxx:2376 ps
</code></pre></div></div> <hr/> <h2 id="security-notes">Security Notes</h2> <ul> <li>⚠️ <strong>Warning</strong>: This exposes Docker daemon without TLS encryption</li> <li>✅ <strong>Safe</strong>: Tailscale provides encrypted VPN tunnel</li> <li>🔒 <strong>Access</strong>: Only devices on your Tailscale network can connect</li> <li>💡 <strong>Firewall</strong>: Uses Tailscale’s CGNAT range (100.64.0.0/10) for access control</li> <li>🛡️ <strong>Best Practice</strong>: Consider using TLS certificates for production environments</li> </ul> <hr/> <h2 id="usage-examples">Usage Examples</h2> <p>Once configured, you can use Docker remotely:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Set environment variable for easier use (replace with your Tailscale IP)</span>
<span class="nb">export </span><span class="nv">DOCKER_HOST</span><span class="o">=</span>tcp://100.xxx.xxx.xxx:2376

<span class="c"># Now use Docker commands normally</span>
docker ps
docker images
docker run hello-world

<span class="c"># Use with docker-compose remotely</span>
docker-compose ps
docker-compose logs
</code></pre></div></div> <hr/> <h2 id="troubleshooting">Troubleshooting</h2> <h3 id="docker-not-listening-on-port-2376">Docker not listening on port 2376:</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check Docker process</span>
ps aux | <span class="nb">grep </span>dockerd

<span class="c"># Check systemd service</span>
<span class="nb">sudo </span>systemctl <span class="nb">cat </span>docker.service | <span class="nb">grep </span>ExecStart

<span class="c"># View Docker logs</span>
<span class="nb">sudo </span>journalctl <span class="nt">-u</span> docker.service <span class="nt">-f</span>
</code></pre></div></div> <h3 id="connection-refused-from-remote">Connection refused from remote:</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check firewall rules</span>
<span class="nb">sudo </span>ufw status
<span class="c"># or</span>
<span class="nb">sudo </span>iptables <span class="nt">-L</span> <span class="nt">-n</span> | <span class="nb">grep </span>2376

<span class="c"># Test local connection first</span>
curl http://localhost:2376/containers/json
</code></pre></div></div> <h3 id="tailscale-connectivity-issues">Tailscale connectivity issues:</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check Tailscale status</span>
tailscale status

<span class="c"># Restart Tailscale</span>
<span class="nb">sudo </span>systemctl restart tailscaled

<span class="c"># Re-authenticate if needed</span>
<span class="nb">sudo </span>tailscale up
</code></pre></div></div> <h3 id="docker-service-wont-start">Docker service won’t start:</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Check for configuration conflicts</span>
<span class="nb">sudo </span>journalctl <span class="nt">-u</span> docker.service <span class="nt">--no-pager</span> <span class="nt">-l</span>

<span class="c"># Temporarily remove custom config</span>
<span class="nb">sudo mv</span> /etc/docker/daemon.json /etc/docker/daemon.json.backup
<span class="nb">sudo </span>systemctl restart docker
</code></pre></div></div>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="Self-hosted"/><category term="Docker"/><category term="Tailscale"/><category term="Proxy"/><category term="Linux"/><summary type="html"><![CDATA[This guide shows how to expose Docker's API (port 2376) to your Tailscale VPN network on Linux systems.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/Docker-API-Exposure-Linux.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/Docker-API-Exposure-Linux.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">The .DS_Store Strikes Back: Finder Edition</title><link href="https://zhipenghe.me/blog/2025/The-DS_Store-Strikes-Back/" rel="alternate" type="text/html" title="The .DS_Store Strikes Back: Finder Edition"/><published>2025-05-11T23:00:00+10:00</published><updated>2025-05-11T23:00:00+10:00</updated><id>https://zhipenghe.me/blog/2025/The-DS_Store-Strikes-Back</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/The-DS_Store-Strikes-Back/"><![CDATA[<blockquote> <p><em>A long time ago, on a remote server far, far away…</em></p> </blockquote> <p>When working with remote servers from macOS, you’ll inevitably encounter the dark side of the Force: hidden system files that follow your every move. These invisible menaces can break deployment scripts, bloat repositories, and generally cause chaos in your otherwise pristine remote environments.</p> <hr/> <h2 id="episode-v-the-ds_store-strikes-back">EPISODE V: THE .DS_STORE STRIKES BACK</h2> <blockquote class="block-danger"> <h5 id="the-dark-side-of-macos">The Dark Side of macOS</h5> <p>Imperial .DS_Store files have driven Rebel developers from their remote server folders. These hidden files spread like the Dark Side across every folder you visit.</p> </blockquote> <hr/> <h2 id="episode-vi-the-ds_store-strikes-back">EPISODE VI: THE .DS_STORE STRIKES BACK</h2> <ul> <li> <p><strong>Darth <code class="language-plaintext highlighter-rouge">.DS_Store</code></strong>: “Your lack of <code class="language-plaintext highlighter-rouge">defaults write</code> is disturbing. My hidden files will spread across every folder you visit.”</p> </li> <li> <p><strong>Luke:</strong> “But I’ve tried <code class="language-plaintext highlighter-rouge">.gitignore</code>! It has no power here on remote connections!”</p> </li> <li> <p><strong>Yoda:</strong> “Use the Terminal, Luke. Or the sacred command to disable .DS_Store on network volumes.”</p> </li> <li> <p><strong>Han:</strong> “I’ve been running from these hidden files for ten years. Not a remote server is safe in the galaxy!”</p> </li> </ul> <h3 id="the-solution-clean-up-the-dark-side">The Solution: Clean Up the Dark Side</h3> <ol> <li> <p><strong>Remove Existing .DS_Store Files</strong></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Clean up all .DS_Store files</span>
find <span class="nb">.</span> <span class="nt">-name</span> <span class="s2">".DS_Store"</span> <span class="nt">-delete</span>
</code></pre></div> </div> </li> <li> <p><strong>Prevent Future .DS_Store Creation</strong></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Disable .DS_Store on network volumes</span>
defaults write com.apple.desktopservices DSDontWriteNetworkStores <span class="nt">-bool</span> TRUE

<span class="c"># Restart Finder to apply changes</span>
killall Finder
</code></pre></div> </div> </li> </ol> <hr/> <h2 id="episode-vi-the-return-of-the-metadata-_">EPISODE VI: THE RETURN OF THE METADATA (._*)</h2> <blockquote> <p><em>“The ._* files are back, and they’re more annoying than ever.”</em></p> </blockquote> <p>!!! warning “The Hidden Menace” When you copy files to a remote server, macOS creates mysterious <code class="language-plaintext highlighter-rouge">._*</code> files. They’re like the Ewoks of the file system - small, seemingly harmless, but they can cause big problems.</p> <ul> <li> <p><strong>Darth Metadata</strong>: “Your files are not complete without my metadata. I will follow them everywhere.”</p> </li> <li> <p><strong>Luke</strong>: “But these <code class="language-plaintext highlighter-rouge">._*</code> files are causing issues with my Python scripts!”</p> </li> <li> <p><strong>Yoda</strong>: “Hidden they are, but dangerous they can be. Clean them you must.”</p> </li> </ul> <h3 id="the-solution-defeat-the-metadata-menace">The Solution: Defeat the Metadata Menace</h3> <ol> <li> <p><strong>Remove Existing Metadata Files</strong></p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Remove all ._ files</span>
find <span class="nb">.</span> <span class="nt">-name</span> <span class="s2">"._*"</span> <span class="nt">-delete</span>

<span class="c"># Or clean both .DS_Store and ._ files</span>
find <span class="nb">.</span> <span class="nt">-type</span> f <span class="nt">-name</span> <span class="s2">"._*"</span> <span class="nt">-o</span> <span class="nt">-name</span> <span class="s2">".DS_Store"</span> <span class="nt">-delete</span>
</code></pre></div> </div> </li> <li> <p><strong>Understanding the ._* Files - The Unstoppable Force</strong></p> <p>On macOS, preventing the creation of <code class="language-plaintext highlighter-rouge">._*</code> files (AppleDouble metadata) entirely is not officially supported—especially on non-HFS+ or non-APFS volumes.</p> <p><strong>Why ._* files are created:</strong> macOS uses ._* AppleDouble files to store:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>- Resource forks
- Extended attributes (e.g., custom icons, tags)
- Finder metadata

These are automatically created when copying files to filesystems that don't support extended attributes, such as:
- SMB shares (Linux Samba servers)
- FAT, exFAT, NTFS drives
- Some WebDAV volumes
</code></pre></div> </div> </li> <li> <p><strong>The Harsh Reality</strong></p> <p><strong><em>Just kidding, there are no best available solutions for this problem.</em></strong> That’s why using Finder to mount remote servers as local drives is not an elegant solution (Check <a href="/blog/2025/Surviving-without-VS-Code-Remote-SSH/#but--is-this-method-elegant">this</a>.</p> </li> <li> <p><strong>For Git Users</strong></p> <p>Add the following to your .gitignore file:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Add to your .gitignore (won't prevent creation, but prevents tracking)</span>
<span class="nb">echo</span> <span class="s2">"._*</span><span class="se">\n</span><span class="s2">.DS_Store"</span> <span class="o">&gt;&gt;</span> .gitignore
</code></pre></div> </div> </li> </ol> <hr/> <h2 id="final-words">Final Words</h2> <blockquote> <p><em>“The Force is strong with clean file systems.”</em></p> </blockquote> <p>May your remote development be free of metadata files, and may the Force be with you! 🚀</p>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="HPC"/><category term="macOS"/><category term="Linux"/><category term="Aqua"/><summary type="html"><![CDATA[A long time ago, on a remote server far, far away...]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/DS_Store-meme.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/DS_Store-meme.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry><entry><title type="html">Surviving without VS Code Remote SSH</title><link href="https://zhipenghe.me/blog/2025/Surviving-without-VS-Code-Remote-SSH/" rel="alternate" type="text/html" title="Surviving without VS Code Remote SSH"/><published>2025-05-11T12:06:00+10:00</published><updated>2025-05-11T12:06:00+10:00</updated><id>https://zhipenghe.me/blog/2025/Surviving-without-VS-Code-Remote-SSH</id><content type="html" xml:base="https://zhipenghe.me/blog/2025/Surviving-without-VS-Code-Remote-SSH/"><![CDATA[ <blockquote class="block-danger"> <h5 id="vs-code-remote-ssh-is-banned">VS Code Remote SSH is banned</h5> <p>QUT Aqua banned VS Code Remote SSH extension due to potential high workload on the node. Even you try to connect to Aqua through Remote SSH, it will be disconnected automatically after around 30 seconds. Check <a href="https://docs.eres.qut.edu.au/hpc-vscode-usage#using-vs-code-to-edit-files-on-the-hpc">this</a> for more details.</p> </blockquote> <p>So… you’re trying to develop on QUT Aqua, but the server gods have other plans. Maybe you can’t use VS Code Remote SSH. Maybe you’re just feeling adventurous. But do not worry — you can still edit remote files and develop like a champ. Here’s how I’ve kept my sanity while developing on remote HPC systems.</p> <div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/VSCode-Remote-SSH-Error-480.webp 480w,/assets/img/posts/VSCode-Remote-SSH-Error-800.webp 800w,/assets/img/posts/VSCode-Remote-SSH-Error-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/VSCode-Remote-SSH-Error.png" class="img-fluid rounded z-depth-1 w-75" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> VS Code Remote SSH is banned by QUT Aqua due to potential high workload on the node. </div> <hr/> <h2 id="before-you-start">Before you start</h2> <h3 id="recommend-to-add-a-shortcut-to-sshconfig">Recommend to add a shortcut to <code class="language-plaintext highlighter-rouge">~/.ssh/config</code></h3> <p>If you are using SSH keys to connect to the HPC, you can add a shortcut to <code class="language-plaintext highlighter-rouge">~/.ssh/config</code> to make your life easier. QUT Aqua documentation provides a <a href="https://docs.eres.qut.edu.au/hpc-getting-started-with-high-performance-computin#how-you-log-into-aqua-depends-on-the-operating-system-of-your-computer">guide</a> on how to set up SSH keys for passwordless login.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Add to your ~/.ssh/config</span>
Host aqua
    HostName aqua.qut.edu.au
    User your-username
    IdentityFile ~/.ssh/id_rsa_aqua <span class="c"># Add your SSH key here</span>
    ServerAliveInterval 60
</code></pre></div></div> <p>Then, you can connect to the HPC by running <code class="language-plaintext highlighter-rouge">ssh aqua</code>. Also, you can use <code class="language-plaintext highlighter-rouge">aqua</code> to replace <code class="language-plaintext highlighter-rouge">your-username@aqua.qut.edu.au</code> in the following commands.</p> <hr/> <h2 id="-1-fake-it-with-ssh-mounted-folders">🧩 1. Fake it with SSH-mounted folders</h2> <h3 id="-option-a-mount-via-finder">🧀 Option A: Mount via Finder</h3> <blockquote> <p>Here’s a quick guide for macOS users. Please refer to the <a href="https://docs.eres.qut.edu.au/hpc-transferring-files-tofrom-hpc#using-file-explorer-or-finder-to-mount-a-drive-to-the-hpc">QUT Aqua documentation</a> for other OS.</p> </blockquote> <ol> <li>Open <strong>Finder</strong> → <code class="language-plaintext highlighter-rouge">Go</code> → <code class="language-plaintext highlighter-rouge">Connect to Server...</code></li> <li> <p>Enter:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>smb://hpc-fs/home/
</code></pre></div> </div> </li> <li>Mount it, then open the folder in VS Code like it’s 1999.</li> </ol> <p>📝 <em>Note</em>: You can edit files, but <strong>no shell</strong>, <strong>no Git</strong>, and no terminal tantrums. It’s like eating cake without the frosting.</p> <h4 id="but--is-this-method-elegant">But … is this method elegant?</h4> <p>You’ve mounted an SMB share to your Finder. While this works, there are some significant limitations:</p> <ol> <li><strong>Git Limitations</strong> - Your version control system becomes severely limited over SMB. Git operations that work fine locally will fail or behave unpredictably through the mounted share.</li> <li><strong>VS Code Terminal Issues</strong> - The integrated terminal in VS Code won’t work properly with mounted SMB shares. You’ll get <code class="language-plaintext highlighter-rouge">Command not found</code> errors for most terminal operations.</li> <li><strong>Connection Stability</strong> - SMB connections can drop unexpectedly, especially during longer work sessions or when the network is unstable.</li> <li><strong>HPC Dependency</strong> - Since all your files live on the server, any HPC maintenance or downtime makes your work completely inaccessible.</li> <li><strong>The .DS_Store Problem (macOS)</strong> - Your Mac will create <code class="language-plaintext highlighter-rouge">.DS_Store</code> files in every folder you visit through Finder. These desktop service files clutter the HPC filesystem and serve no purpose on the server.</li> </ol> <div class="text-center mt-3"> <figure> <picture> <source class="responsive-img-srcset" srcset="/assets/img/posts/DS_Store-meme-480.webp 480w,/assets/img/posts/DS_Store-meme-800.webp 800w,/assets/img/posts/DS_Store-meme-1400.webp 1400w," type="image/webp" sizes="95vw"/> <img src="/assets/img/posts/DS_Store-meme.png" class="img-fluid rounded z-depth-1 w-50" width="100%" height="auto" loading="eager" onerror="this.onerror=null; $('.responsive-img-srcset').remove();"/> </picture> </figure> </div> <div class="caption" style="font-style: italic;"> <b>For macOS users only:</b> Check out <a href="/blog/2025/The-DS_Store-Strikes-Back">The .DS_Store Strikes Back: Finder Edition</a> about how to solve it (or not). </div> <p><br/></p> <h3 id="-option-b-sshfs--mount-through-ssh-wizardry">🔧 Option B: SSHFS — Mount through SSH Wizardry</h3> <p>Mount your HPC home directory <em>directly</em> via SSH, no Finder fluff. It’s like having your HPC filesystem in your pocket.</p> <h4 id="for-macos-users">For macOS Users:</h4> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install the prerequisites (because your Mac doesn't come with everything, despite what Apple claims)</span>
brew <span class="nb">install </span>macfuse
brew <span class="nb">install </span>gromgit/fuse/sshfs-mac

<span class="c"># Mount your HPC home (1)</span>
<span class="nb">mkdir</span> ~/aqua
sshfs your-username@aqua.qut.edu.au:/home/your-username ~/aqua <span class="c">#(2)</span>

<span class="c"># When you're done pretending these files are local</span>
umount ~/aqua
<span class="c"># Or if that fails spectacularly (as technology loves to do)</span>
diskutil unmount ~/aqua
</code></pre></div></div> <p>Notes:</p> <ol> <li>When you’re running <code class="language-plaintext highlighter-rouge">sshfs</code> first time, you will be asked to go to “System Preferences” → “Security &amp; Privacy” → “Security” → click “Allow” for running the app. Then you also need to restart your Mac.</li> <li>You can use <code class="language-plaintext highlighter-rouge">aqua</code> to replace <code class="language-plaintext highlighter-rouge">your-username@aqua.qut.edu.au</code> if you have added a shortcut to <code class="language-plaintext highlighter-rouge">~/.ssh/config</code>.</li> </ol> <h4 id="for-linux-users-ubuntu">For Linux Users (Ubuntu):</h4> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Install SSHFS (because of course Linux makes you work for everything)</span>
<span class="nb">sudo </span>apt <span class="nb">install </span>sshfs

<span class="c"># Mount your HPC home, telling the laws of physics to take a break</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> ~/aqua
sshfs your-username@aqua.qut.edu.au:/home/your-username ~/aqua <span class="nt">-o</span> follow_symlinks

<span class="c"># To send these files back to their natural habitat</span>
fusermount <span class="nt">-u</span> ~/aqua
</code></pre></div></div> <h4 id="for-windows-users">For Windows Users:</h4> <p>Install <a href="https://github.com/winfsp/winfsp/releases">WinFSP</a> and <a href="https://github.com/winfsp/sshfs-win/releases">SSHFS-Win</a>, because Windows needs two separate things to do what other systems accomplish with one. Then use Windows Explorer (which Microsoft keeps renaming as if that will make us forget its bugs) to map a network drive:</p> <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>\\sshfs\your-username@aqua.qut.edu.au
</code></pre></div></div> <p>Then open it in VS Code like you’ve just performed a miracle:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>code ~/aqua
</code></pre></div></div> <p>✅ <em>Pro</em>: Looks local. Feels local. Git operations work… until they mysteriously don’t</p> <p>❌ <em>Con</em>: Feels <strong>too</strong> local for large files. Might lag. If the connection drops, your filesystem freezes like it’s seen a ghost</p> <blockquote class="block-tip"> <h5 id="performance-tips-that-might-help-no-promises">Performance Tips That Might Help (No Promises)</h5> <ul> <li>Use <code class="language-plaintext highlighter-rouge">-o cache=yes</code> to create the illusion of performance (side effects may include file synchronization existential crises)</li> <li>Add <code class="language-plaintext highlighter-rouge">-o compression=yes</code> to squeeze your data through the internet tubes more efficiently</li> <li>If everything hangs, adjust your <code class="language-plaintext highlighter-rouge">ServerAlive</code> settings, which is like giving your connection a gentle nudge every few minutes to check if it’s still breathing</li> </ul> </blockquote> <blockquote class="block-tip"> <h5 id="working-with-git-over-sshfs-a-tragicomedy">Working with Git Over SSHFS: A Tragicomedy</h5> <p>When using Git over SSHFS, you’re essentially asking Git to perform a synchronized swimming routine while blindfolded. For anything more complex than a simple commit, consider SSH-ing directly into the server and running Git commands there. Your future self will thank you for not testing the limits of your patience.</p> </blockquote> <hr/> <h2 id="-2-rsync-scp-and-git-your-old-school-sync-buddies">🔄 2. <code class="language-plaintext highlighter-rouge">rsync</code>, <code class="language-plaintext highlighter-rouge">scp</code> and <code class="language-plaintext highlighter-rouge">git</code>: Your old-school sync buddies</h2> <h3 id="️-option-a-rsync--scp--the-reliable-workhorse">⚙️ Option A: <code class="language-plaintext highlighter-rouge">rsync</code> &amp; <code class="language-plaintext highlighter-rouge">scp</code> — The Reliable Workhorse</h3> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Sync your local code to HPC</span>
rsync <span class="nt">-avz</span> ./my-project/ your-username@aqua.qut.edu.au:/home/your-username/projects/

<span class="c"># Sync back from HPC</span>
rsync <span class="nt">-avz</span> your-username@aqua.qut.edu.au:/home/your-username/projects/ ./my-project/
</code></pre></div></div> <p>Or for a quick one-file fling:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scp script.py your-username@aqua.qut.edu.au:/home/your-username/projects/
</code></pre></div></div> <p>It’s not fancy, but it works — like duct tape.</p> <p><br/></p> <h3 id="-option-b-git--the-version-control-way">⚡ Option B: Git — The Version Control Way</h3> <p>If you are version-controlling your life (as you should), Git is a clean and reliable method.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># On your local machine</span>
git init
git add <span class="nb">.</span>
git commit <span class="nt">-m</span> <span class="s2">"Initial commit"</span>
git remote add aqua your-username@aqua.qut.edu.au:/path/to/repo
git push aqua main

<span class="c"># On the HPC</span>
git clone your-username@aqua.qut.edu.au:/path/to/repo
</code></pre></div></div> <p>✅ <em>Pro</em>: Clean history, branch control, reproducibility</p> <p>❌ <em>Con</em>: Needs initial setup and your SSH keys must behave</p> <hr/> <h2 id="️-3-the-terminal-only-approach">🖥️ 3. The Terminal-Only Approach</h2> <p>When all else fails, embrace the terminal:</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh your-username@aqua.qut.edu.au
</code></pre></div></div> <p>Then pick your weapon of choice:</p> <ul> <li><code class="language-plaintext highlighter-rouge">vim</code> — For the brave</li> <li><code class="language-plaintext highlighter-rouge">nano</code> — For the sane</li> <li><code class="language-plaintext highlighter-rouge">neovim</code> — For the modern</li> <li><code class="language-plaintext highlighter-rouge">emacs</code> — For the… unique</li> </ul> <p>🎯 <em>Bonus</em>: Fast, keyboard-driven, and doesn’t require GUI permission forms.</p> <blockquote> <p><em>Note</em>: I will write another page about how to use <code class="language-plaintext highlighter-rouge">neovim</code> and its plugins to replace VS Code as a lightweight editor (with SSH).</p> </blockquote> <hr/> <h2 id="-4-the-web-based-approach">🌐 4. The Web-Based Approach</h2> <h3 id="-option-a-jupyter-notebooks">📓 Option A: Jupyter Notebooks</h3> <blockquote class="block-tip"> <h5 id="install-jupyter-lab-in-hpc-before-you-start">Install Jupyter Lab in HPC before you start</h5> <p>QUT Aqua documentation provides a <a href="https://docs.eres.qut.edu.au/hpc-accessing-available-software#install-conda">guide</a> on how to install Miniconda in HPC.</p> </blockquote> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># On the HPC</span>
<span class="c"># I prefer to use Jupyter Lab instead of Jupyter Notebook</span>
jupyter lab <span class="nt">--no-browser</span> <span class="nt">--port</span><span class="o">=</span>8888 <span class="c"># (1)</span>

<span class="c"># On your local machine, forward the port 8888 to your local machine</span>
<span class="c"># local_port:localhost:remote_port (2)</span>
ssh <span class="nt">-N</span> <span class="nt">-L</span> 8888:localhost:8888 your-username@aqua.qut.edu.au
</code></pre></div></div> <p>Notes:</p> <ol> <li>If port 8888 is already in use, you can try another port, e.g. 8889.</li> <li><code class="language-plaintext highlighter-rouge">-N</code> means no command to run on the remote machine. <code class="language-plaintext highlighter-rouge">-L</code> means forward the local port to the remote port. Both local and remote ports are 8888 in this case.</li> </ol> <p><br/></p> <h3 id="-option-b-vscode-in-browser">🔥 Option B: VSCode in Browser</h3> <blockquote class="block-warning"> <h5 id="this-might-require-a-sysadmins-blessing">This might require a sysadmin’s blessing!</h5> <p>Fortunately, the server gods haven’t locked <em>everything</em> down:</p> </blockquote> <ol> <li> <p>Install <a href="https://github.com/coder/code-server"><code class="language-plaintext highlighter-rouge">code-server</code></a> on the HPC.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># On HPC server</span>
<span class="c"># Install code-server to your home directory</span>
curl <span class="nt">-fsSL</span> https://code-server.dev/install.sh | sh <span class="nt">-s</span> <span class="nt">--</span> <span class="nt">--method</span> standalone <span class="nt">--prefix</span><span class="o">=</span><span class="nv">$HOME</span>
<span class="c"># code-server will be installed to $HOME/bin/code-server</span>

<span class="c"># check if code-server is installed</span>
code-server <span class="nt">--version</span>

<span class="c"># Start code-server</span>
code-server  <span class="nt">--bind-addr</span> 127.0.0.1:8080 <span class="nt">--disable-telemetry</span> <span class="nt">--disable-update-check</span> <span class="nt">--auth</span> none

<span class="c"># On your local machine</span>
<span class="c"># Forward the port 8080 to your local machine</span>
ssh <span class="nt">-N</span> <span class="nt">-L</span> 8080:127.0.0.1:8080 your-username@aqua.qut.edu.au
</code></pre></div> </div> </li> <li> <p>Open it in your browser</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Open the web page in your browser</span>
http://localhost:8080
</code></pre></div> </div> </li> <li> <p>Marvel as VS Code rises from the ashes — web-style</p> </li> </ol> <blockquote class="block-tip"> <h5 id="sync-vs-code-settings-to-code-server">Sync VS Code settings to code-server</h5> <p>You can import your VS Code settings to code-server by importing the profile from VS Code. Check out <a href="https://code.visualstudio.com/docs/configure/profiles#_share-profiles">this page</a> for more details about how to export and import profiles. However, this’s not the perfect solution. Not all VS Code extensions are available for code-server, some extensions are restricted for Microsoft VS Code. Only the extensions that are available for code-server are listed in <a href="https://open-vsx.org/">Open VSX Registry</a>.</p> </blockquote> <h4 id="run-code-server-in-the-background-with-tmux">Run code-server in the background with <code class="language-plaintext highlighter-rouge">tmux</code></h4> <p>You can run code-server in the background with <code class="language-plaintext highlighter-rouge">tmux</code> to avoid the session being killed after you disconnect from the HPC.</p> <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Start a new tmux session</span>
tmux new <span class="nt">-s</span> code

<span class="c"># Run code-server in the background</span>
code-server <span class="nt">--bind-addr</span> 127.0.0.1:8080 <span class="nt">--disable-telemetry</span> <span class="nt">--disable-update-check</span> <span class="nt">--auth</span> none

<span class="c"># Detach from the tmux session: `Ctrl+b`, then `d`</span>

<span class="c"># Reattach to the tmux session</span>
tmux attach <span class="nt">-t</span> code

<span class="c"># Kill the tmux session</span>
tmux kill-session <span class="nt">-t</span> code

<span class="c"># If you forget the session name, you can list all sessions</span>
tmux <span class="nb">ls</span>
</code></pre></div></div> <h4 id="known-issue-on-integrated-terminal-and-extension-host">Known issue on Integrated Terminal and Extension Host</h4> <p>I found that the terminal and the extension host are not stable when using code-server. The issue seems to revolve around the <strong>ptyHost</strong>, <strong>File Watcher</strong>, and <strong>Extension Host</strong>, and it’s being <strong>repeatedly killed by SIGTERM</strong>.</p> <p>🧠 <strong>What Is Happening?</strong></p> <pre><code class="language-log">[12:18:01] ptyHost terminated unexpectedly with code null
[12:18:01] [File Watcher (universal)] restarting watcher after unexpected error: terminated by itself with code null, signal: SIGTERM (ETERM)
[12:18:01] [127.0.0.1][d0f383fd][ExtensionHostConnection] &lt;3126357&gt; Extension Host Process exited with code: null, signal: SIGTERM.
[12:18:02] [127.0.0.1][d0f383fd][ExtensionHostConnection] Unknown reconnection token (seen before).
[12:18:02] [127.0.0.1][368c67ad][ExtensionHostConnection] New connection established.
[12:18:02] [127.0.0.1][368c67ad][ExtensionHostConnection] &lt;3132486&gt; Launched Extension Host Process.
</code></pre> <p>📜 <strong>From the logs:</strong></p> <ul> <li>💥 The <code class="language-plaintext highlighter-rouge">ptyHost</code> process (responsible for terminal sessions) crashed or was killed — possibly due to system resource limits or policy.</li> <li>📦 File watcher was forcefully killed (SIGTERM) — system or job policy likely did this.</li> <li>🧩 Extension host was also killed — same reason, likely tied to HPC rules.</li> <li>↩️ code-server tried to reconnect to the crashed extension host but failed.</li> <li>🆕 code-server restarted the extension host process automatically.</li> </ul> <hr/> <h2 id="tldr--what-works-and-what-requires-sacrifice">TL;DR — What Works (and What Requires Sacrifice)</h2> <table> <thead> <tr> <th>🛠️ Method</th> <th>🧑‍💻 Edit in VS Code</th> <th>🖥️ Terminal Access</th> <th>📂 Where Files Live</th> <th>🖼️ GUI Needed</th> <th>⚡ Vibe Check</th> </tr> </thead> <tbody> <tr> <td><strong>SMB (Finder)</strong></td> <td>✅ Yes, like it’s local</td> <td>❌ Nope, just files</td> <td>🌐 Remote (mounted)</td> <td>✅ Yes</td> <td>🧀 “Cheesy but it works”</td> </tr> <tr> <td><strong>SSHFS</strong></td> <td>✅ Yes (mostly)</td> <td>❌ Not really</td> <td>🌐 Remote (mounted)</td> <td>❌ Nope</td> <td>🐢 “Kinda slow, kinda cool”</td> </tr> <tr> <td><strong>rsync / Git</strong></td> <td>✅ Edit local, sync later</td> <td>✅ Full control</td> <td>📂 Local (then synced)</td> <td>❌ Nope</td> <td>🔨 “Old school, solid”</td> </tr> <tr> <td><strong>Terminal Editors</strong></td> <td>❌ No GUI, no problem</td> <td>✅ Born in the terminal</td> <td>🌐 Remote (SSH only)</td> <td>❌ Nope</td> <td>💀 “For shell warriors”</td> </tr> <tr> <td><strong>Jupyter</strong></td> <td>✅ Yes, via browser</td> <td>✅ If allowed</td> <td>🌐 Remote (Jupyter workspace)</td> <td>✅ Yes</td> <td>🧪 “Science with style”</td> </tr> <tr> <td><strong>code-server</strong></td> <td>✅ Yes, but web-based</td> <td>❓ Unstable</td> <td>🌐 Remote (in browser)</td> <td>✅ Yes</td> <td>🧙 “Feels like cheating”</td> </tr> </tbody> </table> <hr/> <h2 id="final-words">Final Words</h2> <p>Remote development on HPC doesn’t have to be a pain. Pick your poison, set up your workflow, and remember: the best development environment is the one that doesn’t make you want to throw your computer out the window.</p> <p>Happy coding, and may your HPC connections be stable! 🚀</p>]]></content><author><name>Zhipeng &quot;Zippo&quot; He 何志鹏</name></author><category term="HPC"/><category term="Aqua"/><category term="VSCode"/><category term="SSH"/><summary type="html"><![CDATA[Or: "They took away my extension, but not my will to code."]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://zhipenghe.me/assets/img/posts/VSCode-Remote-SSH-Error.png"/><media:content medium="image" url="https://zhipenghe.me/assets/img/posts/VSCode-Remote-SSH-Error.png" xmlns:media="http://search.yahoo.com/mrss/"/></entry></feed>