<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Timo</title>
    <description>The latest articles on DEV Community by Timo (@hungrykoala).</description>
    <link>https://dev.to/hungrykoala</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3976402%2Fb850339b-b778-46a0-ab57-ccb927757c9e.png</url>
      <title>DEV Community: Timo</title>
      <link>https://dev.to/hungrykoala</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hungrykoala"/>
    <language>en</language>
    <item>
      <title>Caddy as a Reverse Proxy: From 3 Lines to a Real Setup</title>
      <dc:creator>Timo</dc:creator>
      <pubDate>Sat, 13 Jun 2026 16:08:00 +0000</pubDate>
      <link>https://dev.to/hungrykoala/caddy-as-a-reverse-proxy-from-3-lines-to-a-real-setup-7oe</link>
      <guid>https://dev.to/hungrykoala/caddy-as-a-reverse-proxy-from-3-lines-to-a-real-setup-7oe</guid>
      <description>&lt;p&gt;Caddy is probably the easiest reverse proxy I have used so far.&lt;/p&gt;

&lt;p&gt;That does not mean it is only useful for tiny setups. My KoalaStuff server uses Caddy for static sites, Docker apps, redirects, custom headers, access logs, API proxying, service worker caching and a few other little quality-of-life things.&lt;/p&gt;

&lt;p&gt;But the nice part is: you do not have to start with all of that.&lt;/p&gt;

&lt;p&gt;You can start with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that is already a working reverse proxy with HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: what this tutorial assumes
&lt;/h2&gt;

&lt;p&gt;Before the config examples, there are a few small things that are easy to forget when you already know them.&lt;/p&gt;

&lt;p&gt;This post assumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you have a domain or subdomain pointing to your server&lt;/li&gt;
&lt;li&gt;ports &lt;code&gt;80&lt;/code&gt; and &lt;code&gt;443&lt;/code&gt; are reachable from the internet&lt;/li&gt;
&lt;li&gt;Caddy is running on the same server as your apps&lt;/li&gt;
&lt;li&gt;your apps are usually running in Docker&lt;/li&gt;
&lt;li&gt;your Docker containers share a network with Caddy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In my setup, that shared Docker network is called &lt;code&gt;caddy_net&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That part matters because it decides whether you can use Docker container names like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reverse_proxy ghost_blog:2368
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or whether you need to use an IP address instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reverse_proxy 192.168.178.50:2368
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Caddy and the app are in the same Docker network, Caddy can reach the app by its container or service name.&lt;/p&gt;

&lt;p&gt;If they are not in the same Docker network, Caddy has no idea what &lt;code&gt;ghost_blog&lt;/code&gt; is. In that case, use the local IP address and port instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caddyfile is not YAML
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;docker-compose.yml&lt;/code&gt; file is YAML. Indentation matters a lot there.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;Caddyfile&lt;/code&gt; is not YAML. It just uses blocks with &lt;code&gt;{ }&lt;/code&gt;, which can make it look a little bit YAML-ish if you squint at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smallest useful reverse proxy
&lt;/h2&gt;

&lt;p&gt;The simplest useful Caddy reverse proxy looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your DNS points &lt;code&gt;my.domain.com&lt;/code&gt; to your server and Caddy can reach &lt;code&gt;my-app:3000&lt;/code&gt;, this already does the important stuff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accepts requests for &lt;code&gt;my.domain.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;gets HTTPS certificates automatically&lt;/li&gt;
&lt;li&gt;forwards traffic to your app&lt;/li&gt;
&lt;li&gt;sends the response back to the visitor&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a lot of small self-hosted apps, that is already enough.&lt;/p&gt;

&lt;p&gt;For example, if you run something like Uptime Kuma in Docker and the service is called &lt;code&gt;uptime-kuma&lt;/code&gt;, your Caddy block might be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status.example.com {
    reverse_proxy uptime-kuma:3001
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the part I like about Caddy. You do not need a giant config file just to put one app behind HTTPS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker names vs IP addresses
&lt;/h2&gt;

&lt;p&gt;This is probably the most common beginner mistake.&lt;/p&gt;

&lt;p&gt;This works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But only if Caddy can resolve &lt;code&gt;my-app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Docker, that usually means both containers are in the same Docker network.&lt;/p&gt;

&lt;p&gt;A simplified &lt;code&gt;docker-compose.yml&lt;/code&gt; could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_net&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./Caddyfile:/etc/caddy/Caddyfile&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_config:/config&lt;/span&gt;

  &lt;span class="na"&gt;my-app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-app-image&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_net&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_net&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

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

&lt;/div&gt;



&lt;p&gt;In my case, I use one shared Docker network called &lt;code&gt;caddy_net&lt;/code&gt; and attach the services that should be reachable by Caddy.&lt;/p&gt;

&lt;p&gt;Then Caddy can use names like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reverse_proxy ghost_blog:2368
reverse_proxy uptime-kuma:3001
reverse_proxy casdoor:8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your app is not in the same Docker network, use an IP address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    reverse_proxy 192.168.178.50:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is nothing wrong with that. Docker names are just cleaner when everything runs on the same Docker host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding compression
&lt;/h2&gt;

&lt;p&gt;The next small upgrade is compression.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    encode zstd gzip
    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Caddy to compress responses when the browser supports it.&lt;/p&gt;

&lt;p&gt;I usually add this to most of my sites because it is simple and saves some bandwidth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding basic security headers
&lt;/h2&gt;

&lt;p&gt;You can also let Caddy add HTTP headers.&lt;/p&gt;

&lt;p&gt;A small baseline could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=31536000"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }

    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What these do:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Strict-Transport-Security&lt;/code&gt; tells browsers to prefer HTTPS for this domain.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;X-Content-Type-Options "nosniff"&lt;/code&gt; tells browsers not to guess file types.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Referrer-Policy&lt;/code&gt; limits how much referrer information gets sent to other sites.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-Server&lt;/code&gt; removes the &lt;code&gt;Server&lt;/code&gt; header.&lt;/p&gt;

&lt;p&gt;A small warning about HSTS: do not blindly add &lt;code&gt;includeSubDomains; preload&lt;/code&gt; unless you understand what that means. It can affect all subdomains and is annoying to undo if you set it too early.&lt;/p&gt;

&lt;p&gt;For a first setup, keep it simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with repeating yourself
&lt;/h2&gt;

&lt;p&gt;If you have one domain, this is fine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    encode zstd gzip

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }

    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But once you have five, ten or twenty subdomains, copy-pasting the same headers everywhere gets messy.&lt;/p&gt;

&lt;p&gt;That is where snippets become useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Snippets: reusable Caddy blocks
&lt;/h2&gt;

&lt;p&gt;A snippet is a reusable block in your Caddyfile.&lt;/p&gt;

&lt;p&gt;It looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(base) {
    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you can import it into a site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    import base
    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is one of the main reasons my Caddyfile is still readable.&lt;/p&gt;

&lt;p&gt;Most of my domains start with something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;some.koalastuff.net {
    import json_log some-name
    import base
    reverse_proxy service-name:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The site block stays short, and the shared defaults live in one place.&lt;/p&gt;

&lt;p&gt;You can also import snippets inside snippets themselves, for example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(base) {
    import cat_errors
    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        X-XSS-Protection "1; mode=block"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives every imported site:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;compression&lt;/li&gt;
&lt;li&gt;my custom error handler&lt;/li&gt;
&lt;li&gt;a few basic security headers&lt;/li&gt;
&lt;li&gt;the removed &lt;code&gt;Server&lt;/code&gt; header&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is my personal default. It is not something everyone should copy without thinking.&lt;/p&gt;

&lt;p&gt;For example, &lt;code&gt;X-Frame-Options "SAMEORIGIN"&lt;/code&gt; is fine for many normal sites, but it can break things if you actually want your page to be embedded somewhere.&lt;/p&gt;

&lt;p&gt;Same with strict HSTS settings. They are useful, but you should know when you are ready to use them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom error pages with http.cat
&lt;/h2&gt;

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

&lt;p&gt;This part is not required, but I like it.&lt;/p&gt;

&lt;p&gt;Instead of boring error pages, I use a tiny error handler with http.cat:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(cat_errors) {
    handle_errors {
        header Content-Type "text/html; charset=utf-8"
        respond "&amp;lt;body style='margin:0;background:#000;display:flex;justify-content:center;align-items:center;height:100vh;'&amp;gt;&amp;lt;img src='https://http.cat/{http.error.status_code}'&amp;gt;&amp;lt;/body&amp;gt;" {http.error.status_code}
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interesting part is this placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{http.error.status_code}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the error is &lt;code&gt;404&lt;/code&gt;, it shows the 404 cat.&lt;/p&gt;

&lt;p&gt;If it is &lt;code&gt;502&lt;/code&gt;, it shows the 502 cat.&lt;/p&gt;

&lt;p&gt;It is not some serious enterprise error page. It is just a small thing that makes my sites feel a little more like mine.&lt;/p&gt;

&lt;p&gt;And because it is a snippet, I can include it through my &lt;code&gt;base&lt;/code&gt; snippet and forget about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing to keep in mind:&lt;/strong&gt; if you use a strict Content Security Policy, you have to allow &lt;code&gt;http.cat&lt;/code&gt; as an image source.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;header&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;    &lt;span class="kn"&gt;Content-Security-Policy&lt;/span&gt; &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;img-src&lt;/span&gt; &lt;span class="s"&gt;'self'&lt;/span&gt; &lt;span class="s"&gt;data:&lt;/span&gt; &lt;span class="s"&gt;blob:&lt;/span&gt; &lt;span class="s"&gt;https://http.cat&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;object-src&lt;/span&gt; &lt;span class="s"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;frame-ancestors&lt;/span&gt; &lt;span class="s"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="kn"&gt;"&lt;/span&gt;&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, the browser may block the cat image and you will see something like this in the console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Loading the image 'https://http.cat/404' violates the following Content Security Policy directive: "img-src 'self' data: blob:"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So if you use the http.cat error handler, remember to add &lt;code&gt;https://http.cat&lt;/code&gt; to every &lt;code&gt;img-src&lt;/code&gt; directive where the custom error page should work.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON access logs
&lt;/h2&gt;

&lt;p&gt;For logs, I use another snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(json_log) {
    log {
        output file /var/log/caddy/{args.0}.access.log {
            roll_size 15mb
            roll_keep 3
        }
        format json
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;{args.0}&lt;/code&gt; part is useful.&lt;/p&gt;

&lt;p&gt;It lets me import the snippet with a name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blog.example.com {
    import json_log blog
    import base
    reverse_proxy ghost_blog:2368
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That creates a log file like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/log/caddy/blog.access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For another site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status.example.com {
    import json_log status
    import base
    reverse_proxy uptime-kuma:3001
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/log/caddy/status.access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also limit log file size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;roll_size 15mb
roll_keep 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That way logs do not just grow forever.&lt;/p&gt;

&lt;p&gt;You do not need JSON logs on day one. But once you run a few services, separate logs per subdomain are very nice to have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Serving static sites with Caddy
&lt;/h2&gt;

&lt;p&gt;Not every project needs a backend container. Oftentimes, you just want to serve a website that consists entirely of static files without any backend logic at all.&lt;/p&gt;

&lt;p&gt;Some of my KoalaStuff pages are exactly that. This could be a simple, handwritten landing page or documentation. But this is also how you host the output of modern static site generators. If you build slightly more complex sites using frameworks like &lt;a href="https://astro.build/" rel="noopener noreferrer"&gt;Astro&lt;/a&gt; or &lt;a href="https://svelte.dev/" rel="noopener noreferrer"&gt;Svelte&lt;/a&gt; (with their static adapters), you also end up with a simple folder of static files that just need to be served.&lt;/p&gt;

&lt;p&gt;A simple static site block looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sync.example.com {
    import base
    root * /var/www/sync
    file_server
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;root&lt;/code&gt; tells Caddy where the files are.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;file_server&lt;/code&gt; tells Caddy to serve them.&lt;/p&gt;

&lt;p&gt;So when someone visits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://sync.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caddy serves files from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/www/sync
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is great for small frontends, documentation pages, landing pages, tools and static builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static asset caching
&lt;/h2&gt;

&lt;p&gt;For static sites, I use a snippet for file handling and asset caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(static_assets) {
    file_server {
        hide .*
        precompressed zstd br gzip
    }
    @static {
        file
        path *.ico *.css *.js *.png *.svg *.webp *.avif *.woff *.woff2 *.ttf *.jpg
        not path /sw.js
    }
    header @static Cache-Control "public, max-age=31536000, immutable"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This does a few things.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hide .*&lt;/code&gt; stops hidden files from being served. That is useful because you usually do not want files like &lt;code&gt;.env&lt;/code&gt; or &lt;code&gt;.git&lt;/code&gt; to ever be reachable.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;precompressed zstd br gzip&lt;/code&gt; lets Caddy serve already-compressed files if they exist.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;@static&lt;/code&gt; matcher selects common static assets like CSS, JS, images and fonts.&lt;/p&gt;

&lt;p&gt;Then this line adds long-term caching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;header @static Cache-Control "public, max-age=31536000, immutable"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tells the browser it can keep those assets for a long time.&lt;/p&gt;

&lt;p&gt;This works best when your build output uses hashed file names like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app.8f3a2c.js
style.91db0.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because when the file changes, the filename changes too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do not cache your service worker forever
&lt;/h2&gt;

&lt;p&gt;One file I do not want to cache forever is the service worker.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@sw path /sw.js
header @sw Cache-Control "no-cache, no-store, must-revalidate"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Service workers can be annoying if the browser keeps an old version around for too long.&lt;/p&gt;

&lt;p&gt;Normal assets can be cached aggressively.&lt;/p&gt;

&lt;p&gt;The service worker should usually be checked more carefully.&lt;/p&gt;

&lt;p&gt;That is why my static asset snippet excludes &lt;code&gt;/sw.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;not path /sw.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then I handle &lt;code&gt;/sw.js&lt;/code&gt; separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static sites with SPA routing
&lt;/h2&gt;

&lt;p&gt;Many modern frontend apps have client-side routes.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://sync.example.com/settings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There may not be a real &lt;code&gt;/settings&lt;/code&gt; file on disk. The frontend app handles that route in the browser.&lt;/p&gt;

&lt;p&gt;Without a fallback, refreshing that URL can cause a 404.&lt;/p&gt;

&lt;p&gt;For that, I use this snippet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(spa) {
    try_files {path} {path}/index.html {path}.html {path}/
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a static frontend can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sync.example.com {
    import json_log sync
    import base
    import static_assets
    import spa

    root * /var/www/sync
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells Caddy to try the requested path first, but also fall back to HTML files when needed.&lt;/p&gt;

&lt;p&gt;For simple static sites, you might not need this.&lt;/p&gt;

&lt;p&gt;For frontend apps, it can save you from weird refresh-page 404s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reverse proxying real Docker services
&lt;/h2&gt;

&lt;p&gt;Some projects are not static files. They are actual web apps running in containers.&lt;/p&gt;

&lt;p&gt;For those, I use &lt;code&gt;reverse_proxy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A Ghost blog could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;blog.example.com {
    import json_log blog
    import base
    reverse_proxy ghost_blog:2368
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Uptime Kuma:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status.example.com {
    import json_log status
    import base
    reverse_proxy uptime-kuma:3001
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An auth service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth.example.com {
    import json_log auth
    import base
    reverse_proxy auth-service:8000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same pattern again and again.&lt;/p&gt;

&lt;p&gt;That is why I like Caddy for small projects. A new subdomain is often just four lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Forwarded headers
&lt;/h2&gt;

&lt;p&gt;Sometimes I explicitly pass headers to the upstream app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;timer.example.com {
    import base

    reverse_proxy timer-service:3001 {
        header_up Host {host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Real-IP {remote_host}
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This can help the app know which host was requested and what the real client IP was.&lt;/p&gt;

&lt;p&gt;Not every app needs you to set these manually, but some apps behave better when they receive the expected forwarded headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content Security Policy per site
&lt;/h2&gt;

&lt;p&gt;I do not put my Content Security Policy into the global &lt;code&gt;base&lt;/code&gt; snippet.&lt;/p&gt;

&lt;p&gt;The reason is simple: different apps need different rules.&lt;/p&gt;

&lt;p&gt;A very simple static site can have a strict policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;header {
    Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none';"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But another app may need external images, WebSocket connections, iframes, maps or API calls.&lt;/p&gt;

&lt;p&gt;For example, a timer/watch-party app might need YouTube or Twitch frames:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;frame-src 'self' https://player.twitch.tv https://www.youtube.com;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A map-based app might need map tiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;img-src 'self' data: blob: https://*.basemaps.cartocdn.com;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is why CSP should not be treated as one universal copy-paste block.&lt;/p&gt;

&lt;p&gt;My base snippet handles the simple shared headers.&lt;/p&gt;

&lt;p&gt;CSP stays close to the app that actually needs it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proxying external APIs through Caddy
&lt;/h2&gt;

&lt;p&gt;Caddy can also proxy small API routes. I use this when a frontend should call my own domain, while Caddy forwards the request to an external API in the background.&lt;/p&gt;

&lt;p&gt;For example, a small weather endpoint could look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;rewrite /api/weather /v1/forecast?latitude=52.37&amp;amp;longitude=9.73&amp;amp;current=temperature_2m,weather_code&amp;amp;daily=weather_code,temperature_2m_max,temperature_2m_min&amp;amp;timezone=auto&amp;amp;forecast_days=3

reverse_proxy /v1/forecast* https://api.open-meteo.com {
    header_up Host {upstream_hostport}
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the frontend can simply call:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Caddy takes that request and sends it to the external weather API. The frontend does not need to know the full external URL, and the JavaScript code stays a bit cleaner because it only talks to my own domain.&lt;/p&gt;

&lt;p&gt;A nice side effect is that this also helps with security. If the browser only calls my own domain, I can keep my Content Security Policy much stricter. For example, the frontend may only need this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;header {
    Content-Security-Policy "default-src 'self'; connect-src 'self';"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the proxy, I would have to allow the external API directly in &lt;code&gt;connect-src&lt;/code&gt;. That is not always a huge problem, but I prefer keeping the browser-facing side as small and strict as possible.&lt;/p&gt;

&lt;p&gt;This pattern is also useful for frontend-only projects where you do not want to put API keys into the JavaScript bundle. Instead of shipping the key to every visitor, Caddy can add it on the server side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;reverse_proxy /api/example* https://api.example.com {
    header_up Host {upstream_hostport}
    header_up Authorization "Bearer {$EXAMPLE_API_KEY}"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this setup, the API key lives on the server and the browser never receives it directly. Of course, this does not magically make a public endpoint private. If everyone can call &lt;code&gt;/api/example&lt;/code&gt;, they can still use your API key indirectly through your server. For expensive or sensitive APIs, you would still want authentication, rate limiting, or a proper backend. But for small API calls, it is a very handy middle ground.&lt;/p&gt;

&lt;p&gt;Another small privacy benefit is that the actual external API request is made by the server running Caddy. So the external service sees the request coming from your server IP, not as a direct browser request from the visitor. If you care about not forwarding the original client IP in proxy headers either, you can remove those headers explicitly, but for most simple use cases I would keep the basic example clean and only add that when needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  API route example with handle_path
&lt;/h2&gt;

&lt;p&gt;For my small ship/map simulation, I use &lt;code&gt;handle_path&lt;/code&gt; for route and tile proxying.&lt;/p&gt;

&lt;p&gt;A simplified route proxy looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;handle_path /api/route/* {
    rewrite * /route/v1/driving{uri}

    reverse_proxy https://router.project-osrm.org {
        header_up Host {upstream_hostport}
        header_up -X-Forwarded-For
        header_up -X-Real-IP
    }

    header Cache-Control "private, max-age=300"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app calls something under:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Caddy rewrites it and proxies it to OSRM.&lt;/p&gt;

&lt;p&gt;For map tiles, the pattern is similar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;handle_path /api/tiles/light/* {
    rewrite * /light_all{uri}

    reverse_proxy https://a.basemaps.cartocdn.com {
        header_up Host {upstream_hostport}
        header_up -X-Forwarded-For
        header_up -X-Real-IP
    }

    header Cache-Control "public, max-age=86400"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is already a more advanced use case, but it is still readable once you understand the building blocks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;match a path&lt;/li&gt;
&lt;li&gt;rewrite the path&lt;/li&gt;
&lt;li&gt;reverse proxy to another service&lt;/li&gt;
&lt;li&gt;set caching headers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is basically it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects
&lt;/h2&gt;

&lt;p&gt;Redirects are also very simple in Caddy.&lt;/p&gt;

&lt;p&gt;Redirect &lt;code&gt;www&lt;/code&gt; to the root domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;www.example.com {
    redir https://example.com
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirect an old subdomain to a new one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;old.example.com {
    redir https://new.example.com
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirect multiple subdomains to the same target:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;support.example.com, donate.example.com {
    redir https://ko-fi.com/yourname
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the original path with &lt;code&gt;{uri}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;google.example.com {
    redir https://google.com{uri}
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://google.example.com/search?q=caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://google.com/search?q=caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirects are one of those small things that make Caddy really comfortable for personal projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Global options
&lt;/h2&gt;

&lt;p&gt;At the top of my Caddyfile, I also have a global options block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    admin localhost:2019

    metrics {
        per_host
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The global block applies to Caddy itself, not one specific domain.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;metrics&lt;/code&gt; part is useful for monitoring.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;admin&lt;/code&gt; part exposes Caddy's admin API on port &lt;code&gt;2019&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Important: do not expose the admin API publicly without thinking. In my setup, this is meant for internal use and should be protected by network rules, firewall rules or Docker networking. If you do not need it, do not enable it like this.&lt;/p&gt;

&lt;p&gt;A beginner Caddyfile does not need this block.&lt;/p&gt;

&lt;h2&gt;
  
  
  A clean beginner Caddyfile
&lt;/h2&gt;

&lt;p&gt;If you are just starting, I would not begin with the full setup.&lt;/p&gt;

&lt;p&gt;Start with something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    encode zstd gzip

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }

    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is already a good first setup.&lt;/p&gt;

&lt;p&gt;Then add more only when you actually need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A more reusable Caddyfile
&lt;/h2&gt;

&lt;p&gt;Once you have multiple subdomains, move repeated config into snippets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(json_log) {
    log {
        output file /var/log/caddy/{args.0}.access.log {
            roll_size 15mb
            roll_keep 3
        }
        format json
    }
}

(base) {
    encode zstd gzip

    header {
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}

blog.example.com {
    import json_log blog
    import base
    reverse_proxy ghost_blog:2368
}

status.example.com {
    import json_log status
    import base
    reverse_proxy uptime-kuma:3001
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the point where your Caddyfile starts feeling organized instead of copy-pasted.&lt;/p&gt;

&lt;h2&gt;
  
  
  A static frontend example
&lt;/h2&gt;

&lt;p&gt;For a static frontend app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(static_assets) {
    file_server {
        hide .*
        precompressed zstd br gzip
    }

    @static {
        file
        path *.ico *.css *.js *.png *.svg *.webp *.avif *.woff *.woff2 *.ttf *.jpg
        not path /sw.js
    }

    header @static Cache-Control "public, max-age=31536000, immutable"
}

(spa) {
    try_files {path} {path}/index.html {path}.html {path}/
}

app.example.com {
    import base
    import static_assets
    import spa

    root * /var/www/app

    @sw path /sw.js
    header @sw Cache-Control "no-cache, no-store, must-revalidate"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the setup I would use for many small static apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;serve files from &lt;code&gt;/var/www/app&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;cache assets aggressively&lt;/li&gt;
&lt;li&gt;do not aggressively cache the service worker&lt;/li&gt;
&lt;li&gt;support frontend routing&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A more real-world example
&lt;/h2&gt;

&lt;p&gt;Putting the ideas together, a bigger setup can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
    metrics {
        per_host
    }
}

(json_log) {
    log {
        output file /var/log/caddy/{args.0}.access.log {
            roll_size 15mb
            roll_keep 3
        }
        format json
    }
}

(cat_errors) {
    handle_errors {
        header Content-Type "text/html; charset=utf-8"
        respond "&amp;lt;body style='margin:0;background:#000;display:flex;justify-content:center;align-items:center;height:100vh;'&amp;gt;&amp;lt;img src='https://http.cat/{http.error.status_code}'&amp;gt;&amp;lt;/body&amp;gt;" {http.error.status_code}
    }
}

(base) {
    import cat_errors
    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Frame-Options "SAMEORIGIN"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }
}

(static_assets) {
    file_server {
        hide .*
        precompressed zstd br gzip
    }

    @static {
        file
        path *.ico *.css *.js *.png *.svg *.webp *.avif *.woff *.woff2 *.ttf *.jpg
        not path /sw.js
    }

    header @static Cache-Control "public, max-age=31536000, immutable"
}

(spa) {
    try_files {path} {path}/index.html {path}.html {path}/
}

example.com {
    import json_log landing
    import base
    import static_assets
    import spa

    root * /var/www/landing

    header {
        Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none';"
    }
}

blog.example.com {
    import json_log blog
    import base
    reverse_proxy ghost_blog:2368
}

status.example.com {
    import json_log status
    import base
    reverse_proxy uptime-kuma:3001
}

app.example.com {
    import json_log app
    import base
    import static_assets
    import spa

    root * /var/www/app

    @sw path /sw.js
    header @sw Cache-Control "no-cache, no-store, must-revalidate"
}

www.example.com {
    redir https://example.com
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looks much bigger than the first example, but it is still built from the same small pieces.&lt;/p&gt;

&lt;p&gt;That is the main trick.&lt;/p&gt;

&lt;p&gt;Do not try to write the final version on day one.&lt;/p&gt;

&lt;p&gt;Start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my.domain.com {
    reverse_proxy my-app:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then add compression, headers, snippets, logs, static asset handling.&lt;/p&gt;

&lt;p&gt;After that you can focus on your CSP rules per app.&lt;/p&gt;

&lt;p&gt;And finally add API proxying if you need it.&lt;/p&gt;

&lt;p&gt;Caddy stays readable as long as you keep the repeated parts in snippets and avoid turning every site block into a wall of copy-pasted config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best practices I would actually recommend
&lt;/h2&gt;

&lt;p&gt;These are the small habits I would actually recommend after using Caddy for a while:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use Docker networks intentionally.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If Caddy should talk to a container by name, put both containers in the same Docker network. Otherwise, use the server IP or the container IP directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with the smallest config that works.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Do not copy a huge Caddyfile from someone else and paste it straight into production. Start with one working site block and add the extra parts one by one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use snippets once you repeat yourself.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If the same headers, logging setup, or error handling appears in multiple site blocks, it probably belongs in a snippet.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Do not use one CSP for everything.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
A strict static site and a media app with iframes do not need the same Content Security Policy. Keep each policy as strict as possible, but only as strict as that specific app can handle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Be careful with HSTS preload.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
HSTS is useful, but only enable the stronger options once you are sure all important subdomains work correctly over HTTPS. Otherwise, you can lock yourself into a broken setup.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cache static assets, but not your service worker forever.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Long cache times are great for hashed files like CSS, JavaScript, fonts, and images. They are not great for &lt;code&gt;/sw.js&lt;/code&gt;, because an old service worker can make debugging very annoying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keep the admin API private.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If you enable Caddy's admin API, make sure it is not exposed to the public internet. It should only be reachable locally or from a trusted internal network.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Use separate logs when you run many subdomains.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Debugging is much easier when &lt;code&gt;blog&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, and &lt;code&gt;auth&lt;/code&gt; do not all end up in the same messy log file.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thought
&lt;/h2&gt;

&lt;p&gt;The reason I like Caddy is not only that the first setup is easy.&lt;/p&gt;

&lt;p&gt;It is that the simple setup can grow into a real one without turning into a unreadable nightmare you cant easily expand on.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclaimer: This was post was ai assisted, I wrote the draft and ChatGPT 5.5 helped me with formatting, grammar etc.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>caddy</category>
      <category>reverseproxy</category>
      <category>devops</category>
    </item>
    <item>
      <title>Being a developer ruined coding as a hobby for me. Vibe coding brought it back.</title>
      <dc:creator>Timo</dc:creator>
      <pubDate>Thu, 11 Jun 2026 16:25:54 +0000</pubDate>
      <link>https://dev.to/hungrykoala/being-a-developer-ruined-coding-as-a-hobby-for-me-vibe-coding-brought-it-back-1mei</link>
      <guid>https://dev.to/hungrykoala/being-a-developer-ruined-coding-as-a-hobby-for-me-vibe-coding-brought-it-back-1mei</guid>
      <description>&lt;p&gt;I used to love coding in my free time.&lt;/p&gt;

&lt;p&gt;Back in university, it was basically my main hobby. I liked building things, breaking things, trying out random ideas, starting side projects that were probably too big, and learning new tech just because it seemed interesting.&lt;/p&gt;

&lt;p&gt;That was also the reason I wanted to become a developer in the first place.&lt;/p&gt;

&lt;p&gt;Then I actually became one.&lt;br&gt;
And somehow, over time, coding stopped feeling like a hobby.&lt;/p&gt;

&lt;h2&gt;
  
  
  When coding became work
&lt;/h2&gt;

&lt;p&gt;I dropped out of university after around four years and started an apprenticeship as a software developer in May 2019.&lt;/p&gt;

&lt;p&gt;A few months into working full time, the switch already started happening.&lt;/p&gt;

&lt;p&gt;A 40-hour week does not sound that dramatic on paper. But add commuting, breaks, overtime, stress, customer issues, broken promises, late-night fixes, and suddenly opening an IDE after work no longer feels like doing something fun.&lt;/p&gt;

&lt;p&gt;It feels like unpaid overtime.&lt;br&gt;
At my old job, there was always something burning.&lt;/p&gt;

&lt;p&gt;A customer needed something immediately. A feature had been promised in a completely unrealistic timeframe. Something had to be pushed even though everyone knew it was unfinished. Then that unfinished code had to be fixed, patched, worked around, and somehow kept alive.&lt;/p&gt;

&lt;p&gt;There was never really time to clean things up properly.&lt;br&gt;
No real refactors. No calm backlog work. No “let us take a few days and make this better.”&lt;/p&gt;

&lt;p&gt;Just one PRIO 1 emergency chasing the next PRIO 1 emergency.&lt;br&gt;
The backlog did not shrink. It just kept growing.&lt;/p&gt;

&lt;p&gt;At some point, coding became associated with stress in my head. Not creativity. Not curiosity. Not “I wonder if I can build this.”&lt;/p&gt;

&lt;p&gt;Just work.&lt;br&gt;
So I stopped building my own side projects.&lt;br&gt;
And honestly, that sucked.&lt;/p&gt;

&lt;h2&gt;
  
  
  I do not want to code after work. I still want to build things.
&lt;/h2&gt;

&lt;p&gt;That sentence probably explains it best.&lt;/p&gt;

&lt;p&gt;I do not really want to sit down after a full workday and manually type more code.&lt;br&gt;
But I still want to make things.&lt;/p&gt;

&lt;p&gt;I still get ideas. I still run into annoying little problems where I think, “Why does nobody offer a good solution for this?” I still like thinking about architecture, UX, deployment, tradeoffs, naming, features, edge cases, and all the weird details that turn a random idea into a usable tool.&lt;/p&gt;

&lt;p&gt;The part that drained me was not building.&lt;br&gt;
It was being the code monkey after already being the code monkey all day.&lt;/p&gt;

&lt;p&gt;Vibe coding changed that dynamic for me.&lt;br&gt;
Not because AI magically solves everything. It really does not. But because it moved me into a different role.&lt;/p&gt;

&lt;p&gt;Instead of being the person typing every line, I became the person defining the product, the architecture, the constraints, the tech stack, the rules, and the acceptance criteria.&lt;/p&gt;

&lt;p&gt;I know “AI devs” sounds a bit weird, but that is honestly how it feels sometimes: I am not outsourcing thinking, I am outsourcing the typing, scaffolding and first-pass implementation.&lt;/p&gt;

&lt;p&gt;And weirdly, that brought the fun back.&lt;/p&gt;

&lt;h2&gt;
  
  
  From developer to architect / product owner
&lt;/h2&gt;

&lt;p&gt;The biggest shift for me is that I no longer feel like I am doing the same job again after work.&lt;/p&gt;

&lt;p&gt;I am not just grinding through tickets.&lt;br&gt;
I am not being told that some feature needs to be rushed because someone promised it to a customer without asking the developers first.&lt;br&gt;
I am not forced to push code I already know will become technical debt. Instead, I get to make the decisions.&lt;/p&gt;

&lt;p&gt;I define the stack. I define the architecture. I decide what is acceptable and what is not. I can stop a feature and say, “No, this is the wrong direction.” I can throw away an implementation and ask for a different one. I can be strict about CSP, no external CDNs, static builds, self-hosted assets, build-time image optimization, or whatever else matters for the project.&lt;/p&gt;

&lt;p&gt;The AI can suggest things, but it does not get the final word. That part is important to me.&lt;/p&gt;

&lt;p&gt;I am not just asking a chatbot to “make app pls” and then uploading whatever comes out.&lt;br&gt;
I still know what I am doing.&lt;/p&gt;

&lt;p&gt;I studied computer science for years, even if I did not finish the degree. I coded privately before that. I completed an apprenticeship as a software developer with very good grades. I worked professionally as a .NET fullstack developer in a stressful real-world environment.&lt;/p&gt;

&lt;p&gt;I know how to define a tech stack. I know what messy code smells like. I know what architecture decisions cost later. I know when something is over-engineered, under-specified, unsafe, fragile, or just plain stupid.&lt;/p&gt;

&lt;p&gt;That does not mean I catch everything.&lt;br&gt;
But it means I am not blindly accepting whatever the AI gives me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I do not think this is vibe-coded slop
&lt;/h2&gt;

&lt;p&gt;I get why people are skeptical of vibe-coded projects.&lt;/p&gt;

&lt;p&gt;A lot of them are probably garbage. Some are insecure. Some are just wrappers around the same idea everyone else is wrapping. Some look good for five minutes and fall apart the second you do anything slightly unexpected.&lt;/p&gt;

&lt;p&gt;But I do not think the problem is “AI wrote the code.”&lt;br&gt;
The problem is when nobody competent is directing, reviewing, testing, or caring.&lt;/p&gt;

&lt;p&gt;My process is closer to managing a small team of very fast, very annoying junior developers.&lt;/p&gt;

&lt;p&gt;I give detailed instructions. I break tasks down. I define constraints. I ask for verification steps. I make the agent run builds and tests. I review the result. I test the actual product myself. I use my own tools daily. If something feels wrong, I push back.&lt;/p&gt;

&lt;p&gt;Sometimes I let one tool build something and another tool review it.&lt;br&gt;
Sometimes I discuss architecture with ChatGPT or Gemini first, then let Codex, Antigravity or OpenCode implement it.&lt;br&gt;
Sometimes I use Reddit or Google to sanity-check a technology decision before I commit to it.&lt;/p&gt;

&lt;p&gt;The code may be generated, but the product direction is not random.&lt;br&gt;
And that difference matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  KoalaSync was my first real proof point
&lt;/h2&gt;

&lt;p&gt;The clearest example for me is KoalaSync.&lt;/p&gt;

&lt;p&gt;KoalaSync is a free, open-source watch party browser extension. It syncs video playback between people in the same room: play, pause, seeking, episode changes, that kind of thing.&lt;/p&gt;

&lt;p&gt;The original reason I built it was simple:&lt;br&gt;
I watch stuff on my Emby server with friends over Discord, and manually syncing every pause, play and episode change is annoying.&lt;/p&gt;

&lt;p&gt;The usual “3, 2, 1, play” routine gets old very quickly.&lt;br&gt;
Most watch party tools either focus on specific streaming platforms, require accounts, do not work well with self-hosted setups, or feel too closed for what I wanted.&lt;/p&gt;

&lt;p&gt;I wanted something that worked for my own use case first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;browser-based playback&lt;/li&gt;
&lt;li&gt;Emby, Jellyfin and Plex support&lt;/li&gt;
&lt;li&gt;local IPs and custom domains&lt;/li&gt;
&lt;li&gt;no accounts&lt;/li&gt;
&lt;li&gt;no tracking&lt;/li&gt;
&lt;li&gt;open source&lt;/li&gt;
&lt;li&gt;optional self-hosting&lt;/li&gt;
&lt;li&gt;simple room links&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of course it can also work on major browser streaming sites like Netflix, Prime Video, YouTube and others, depending on the player and browser behavior.&lt;br&gt;
But the main reason it exists is still my own problem.&lt;/p&gt;

&lt;p&gt;I needed it. So I built it.&lt;br&gt;
Or more accurately: I designed it, directed it, tested it, reviewed it, and let AI agents write the code.&lt;/p&gt;

&lt;p&gt;I did not manually write a single line of KoalaSync code myself. Not even the tiny fixes.&lt;br&gt;
If a button needed to be slightly bluer, the AI did it.&lt;br&gt;
That sounds absurd, but it was also kind of the whole point.&lt;/p&gt;

&lt;p&gt;I did not want to slowly slide back into the role that made side projects feel like work again. I wanted to stay in the architect / product owner role and see if that was enough to build something real.&lt;/p&gt;

&lt;p&gt;For KoalaSync, it was.&lt;/p&gt;

&lt;p&gt;I know where things are in the codebase. I know what parts do what. I know why decisions were made. I know the backend architecture. I know the frontend structure. I know what the extension is doing and what the relay server is doing.&lt;/p&gt;

&lt;p&gt;But I did not hand-type the implementation. That is a weird sentence to write as a developer. But it is also honestly the thing that made the project possible for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frontend is finally not my problem anymore
&lt;/h2&gt;

&lt;p&gt;I am mainly a backend developer.&lt;/p&gt;

&lt;p&gt;I have written MQTT systems, auth servers, database-heavy backend logic, APIs, and all that kind of stuff. I am comfortable there.&lt;/p&gt;

&lt;p&gt;Frontend, on the other hand, was always the part I disliked most.&lt;br&gt;
Not because I do not care about design. I actually care a lot about how things look and feel.&lt;/p&gt;

&lt;p&gt;But implementing frontend details manually always drained me. CSS edge cases, layout tweaks, browser weirdness, endless tiny UI adjustments — that part was never fun for me.&lt;/p&gt;

&lt;p&gt;AI-assisted coding changed that more than anything else.&lt;/p&gt;

&lt;p&gt;I can describe the design direction, the structure, the behavior, the constraints, the accessibility requirements, the CSP rules, the performance goals, and then review the output instead of fighting every pixel myself.&lt;/p&gt;

&lt;p&gt;That makes frontend work tolerable.&lt;br&gt;
Sometimes even fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  KoalaPull taught me something different
&lt;/h2&gt;

&lt;p&gt;KoalaSync was built on technology I understand very well: HTML, CSS, JavaScript, browser extension APIs, and a JavaScript backend relay server.&lt;/p&gt;

&lt;p&gt;That was mostly me giving very direct architectural instructions.&lt;/p&gt;

&lt;p&gt;KoalaPull was different.&lt;br&gt;
For KoalaPull, I wanted a desktop app, but I did not want to use Electron if I could avoid it. Electron is powerful, but for a small utility it often feels heavier than necessary.&lt;/p&gt;

&lt;p&gt;So I looked into Wails, had never worked with it before.&lt;/p&gt;

&lt;p&gt;That made the AI workflow different. I had to let the tools advise me more actively. I still made the decisions, but I also had to learn along the way. I compared options, read discussions, searched around, asked questions, and used the AI more like a technical guide.&lt;/p&gt;

&lt;p&gt;That was surprisingly enjoyable.&lt;br&gt;
Even though I did not write the code myself, I still learned a lot about the stack, the tradeoffs, and how a Wails app is structured.&lt;/p&gt;

&lt;p&gt;That is another part people sometimes miss. AI-assisted coding does not have to mean you learn nothing.&lt;/p&gt;

&lt;p&gt;If you stay involved, ask why, review decisions, and force yourself to understand the architecture, you can still learn a lot.&lt;/p&gt;

&lt;p&gt;You are just not learning by manually typing every line anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audio compressor update
&lt;/h2&gt;

&lt;p&gt;One of my favorite KoalaSync updates so far is the local audio compressor.&lt;/p&gt;

&lt;p&gt;Modern movie audio is often ridiculous. Dialogue is quiet, explosions are deafening, and you end up constantly adjusting the volume like your remote is part of the movie.&lt;br&gt;
So I added an experimental audio compressor using the Web Audio API.&lt;/p&gt;

&lt;p&gt;For that, I spun up a separate test repository and basically let DeepSeek go wild on prototypes. The goal was to test compression values, understand latency, try different settings, and figure out what actually felt usable.&lt;/p&gt;

&lt;p&gt;The final feature is not magic. It is basically browser-local audio processing using existing Web Audio APIs.&lt;br&gt;
But that is exactly what I like about it.&lt;br&gt;
It solved a real annoyance for me with a fairly simple technical approach.&lt;/p&gt;

&lt;p&gt;And because it runs locally in the browser, it fits the general KoalaSync philosophy: no extra audio service, no accounts, no tracking, no weird cloud processing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Product Hunt went nowhere, and that is fine
&lt;/h2&gt;

&lt;p&gt;I also tried putting KoalaSync on Product Hunt.&lt;br&gt;
And it got completely buried.&lt;/p&gt;

&lt;p&gt;Hundreds of projects launch there, and a free open-source watch party extension for self-hosters is probably not exactly the perfect Product Hunt growth-hack product anyway.&lt;/p&gt;

&lt;p&gt;A lot of that space feels more focused on AI hustle products, quick monetization, and slightly different versions of the same idea.&lt;/p&gt;

&lt;p&gt;That is not really what KoalaSync is.&lt;br&gt;
KoalaSync exists because I wanted it.&lt;/p&gt;

&lt;p&gt;If other people use it, that is great. If they give feedback and the project gets better, even better.&lt;br&gt;
But if it stays niche, that is also fine.&lt;/p&gt;

&lt;p&gt;The biggest win was not Product Hunt traffic.&lt;br&gt;
The biggest win was realizing that I can come home, relax, and build something again without it feeling like my job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vibe coding did not make me stop being a developer
&lt;/h2&gt;

&lt;p&gt;It changed which part of development I spend my free time on. I no longer want to manually code side projects after work.&lt;/p&gt;

&lt;p&gt;But I do want to design them. I do want to architect them. I do want to test them, shape them, polish them, and decide what they should become.&lt;/p&gt;

&lt;p&gt;For me, vibe coding brought back the creative part of software development.&lt;br&gt;
Not the corporate ticket grind. Not the late-night emergency fixes. Not the “ship it now, fix it forever” cycle.&lt;/p&gt;

&lt;p&gt;The part where you have an idea and slowly turn it into something real. That is the part I missed.&lt;br&gt;
And honestly, I am glad I found a way back to it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Discussion
&lt;/h2&gt;

&lt;p&gt;I am curious how other developers feel about this.&lt;/p&gt;

&lt;p&gt;Did AI-assisted coding make side projects more fun for you, or does it just feel like another layer of tooling around the same work?&lt;/p&gt;

&lt;p&gt;And where do you personally draw the line?&lt;/p&gt;

&lt;p&gt;Is a project still “built by me” if I defined the product, architecture, constraints, reviews and testing, but did not manually write the code?&lt;/p&gt;

&lt;p&gt;Or is that still just vibe-coded slop with extra steps?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>discuss</category>
    </item>
    <item>
      <title>I Added a Local Audio Compressor to My Browser Extension</title>
      <dc:creator>Timo</dc:creator>
      <pubDate>Wed, 10 Jun 2026 17:06:12 +0000</pubDate>
      <link>https://dev.to/hungrykoala/i-added-a-local-audio-compressor-to-my-browser-extension-5f8g</link>
      <guid>https://dev.to/hungrykoala/i-added-a-local-audio-compressor-to-my-browser-extension-5f8g</guid>
      <description>&lt;p&gt;I recently added a new feature to my browser extension &lt;a href="https://sync.koalastuff.net" rel="noopener noreferrer"&gt;KoalaSync&lt;/a&gt;: a local audio compressor for browser videos.&lt;/p&gt;

&lt;p&gt;It is still a bit experimental and will probably change over time, but I wanted to write down what I built, why I built it, and what ended up being more annoying than expected.&lt;/p&gt;

&lt;p&gt;KoalaSync is mostly a watch party extension. It syncs play, pause and seeking between people watching videos together in the browser.&lt;/p&gt;

&lt;p&gt;But the new compressor is useful even when you are watching alone, because modern movie audio can be ridiculous.&lt;/p&gt;

&lt;p&gt;One minute the dialogue is so quiet that I turn the volume up.&lt;br&gt;
Thirty seconds later an explosion tries to kill my headphones.&lt;/p&gt;

&lt;p&gt;I mostly watch movies from my Emby server with friends, and I got tired of constantly adjusting the volume. VLC has had audio compression for ages. Some TVs have a "night mode". But in the browser, you usually just get the raw audio mix from the video element.&lt;/p&gt;

&lt;p&gt;So I wanted to see if I could build something similar directly into the extension.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Basic Idea
&lt;/h2&gt;

&lt;p&gt;The browser already has most of what I needed: the Web Audio API.&lt;/p&gt;

&lt;p&gt;More specifically, &lt;code&gt;DynamicsCompressorNode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It all runs locally in the browser, with no separate audio service involved.&lt;/p&gt;

&lt;p&gt;The simplest version looks something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaElementSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoElement&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;compressor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createDynamicsCompressor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;knee&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.010&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;release&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That takes the audio from a video element, runs it through a compressor, and sends it to the speakers.&lt;/p&gt;

&lt;p&gt;That part was honestly not the hard part.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Annoying Part: Switching It On Without Pops
&lt;/h2&gt;

&lt;p&gt;The compressor node itself is easy.&lt;/p&gt;

&lt;p&gt;Making it feel usable inside a browser extension was more annoying.&lt;/p&gt;

&lt;p&gt;I did not want the audio to pop when enabling or disabling the compressor. A hard switch between the original signal and the compressed signal can sound pretty bad, especially when you toggle it while something is already playing.&lt;/p&gt;

&lt;p&gt;So I ended up using a dry/wet setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dry path: original audio&lt;/li&gt;
&lt;li&gt;wet path: compressed audio&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When the compressor turns on, the dry signal fades out and the compressed signal fades in. When it turns off, the same thing happens in reverse.&lt;/p&gt;

&lt;p&gt;The actual implementation wraps this in a small &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js#L231-L236" rel="noopener noreferrer"&gt;&lt;code&gt;rampGain&lt;/code&gt;&lt;/a&gt; helper that cancels scheduled values first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rampGain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancelScheduledValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linearRampToValueAtTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is just a tiny 40ms ramp, but it makes the toggle feel much less rough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Per-Video Audio Chains
&lt;/h2&gt;

&lt;p&gt;Another thing I had to deal with: pages can have more than one video element.&lt;/p&gt;

&lt;p&gt;And video elements can disappear when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you navigate&lt;/li&gt;
&lt;li&gt;a new episode loads&lt;/li&gt;
&lt;li&gt;the player re-renders itself&lt;/li&gt;
&lt;li&gt;the site swaps one media element for another&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So each video element gets its own audio chain.&lt;/p&gt;

&lt;p&gt;I cache those chains in a &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js#L156" rel="noopener noreferrer"&gt;&lt;code&gt;WeakMap&lt;/code&gt;&lt;/a&gt;, so when the video element disappears, the browser can clean up the related chain as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;audioChains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;WeakMap&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setupAudioChain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioChains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;audioChains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="c1"&gt;// ... create source, compressor, dryGain, compGain&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;compressor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dryGain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;compGain&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;audioChains&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;videoEl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The full &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js#L201-L229" rel="noopener noreferrer"&gt;&lt;code&gt;setupAudioChain&lt;/code&gt;&lt;/a&gt; also creates the dry/wet gain nodes and connects everything.&lt;/p&gt;

&lt;p&gt;This is one of those things where the idea is simple, but browser pages are messy enough that it still takes some care.&lt;/p&gt;




&lt;h2&gt;
  
  
  Presets Instead of Only Sliders
&lt;/h2&gt;

&lt;p&gt;I did not want users to open the feature and immediately stare at five audio parameters.&lt;/p&gt;

&lt;p&gt;So I added a few &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js#L140-L146" rel="noopener noreferrer"&gt;presets&lt;/a&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Preset&lt;/th&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;th&gt;Attack&lt;/th&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;Knee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Recommended&lt;/td&gt;
&lt;td&gt;-24 dB&lt;/td&gt;
&lt;td&gt;8:1&lt;/td&gt;
&lt;td&gt;10 ms&lt;/td&gt;
&lt;td&gt;300 ms&lt;/td&gt;
&lt;td&gt;15 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic Range&lt;/td&gt;
&lt;td&gt;-18 dB&lt;/td&gt;
&lt;td&gt;4:1&lt;/td&gt;
&lt;td&gt;20 ms&lt;/td&gt;
&lt;td&gt;200 ms&lt;/td&gt;
&lt;td&gt;10 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vocal Enhancement&lt;/td&gt;
&lt;td&gt;-12 dB&lt;/td&gt;
&lt;td&gt;3:1&lt;/td&gt;
&lt;td&gt;15 ms&lt;/td&gt;
&lt;td&gt;150 ms&lt;/td&gt;
&lt;td&gt;5 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smooth&lt;/td&gt;
&lt;td&gt;-30 dB&lt;/td&gt;
&lt;td&gt;1.5:1&lt;/td&gt;
&lt;td&gt;30 ms&lt;/td&gt;
&lt;td&gt;250 ms&lt;/td&gt;
&lt;td&gt;20 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default is currently "Recommended".&lt;/p&gt;

&lt;p&gt;It is not meant to be perfect. It just tries to make dialogue easier to hear without completely destroying the sound of action scenes.&lt;/p&gt;

&lt;p&gt;There is also a custom mode where you can tweak threshold, ratio, attack, release and knee manually.&lt;/p&gt;




&lt;h2&gt;
  
  
  Moving Settings Out of the Popup
&lt;/h2&gt;

&lt;p&gt;This was also the first time I added a proper internal extension page to KoalaSync.&lt;/p&gt;

&lt;p&gt;Before this, most settings lived inside the extension popup. That works fine for small toggles, but it gets annoying fast once you want sliders, presets, explanations and maybe more audio tools later.&lt;/p&gt;

&lt;p&gt;So the audio processing UI now opens in its own full tab at &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/audio-options.html" rel="noopener noreferrer"&gt;&lt;code&gt;audio-options.html&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That gives me much more space to work with, and it also leaves room for future features like an equalizer or other audio processing options.&lt;/p&gt;

&lt;p&gt;The popup is still fine for quick actions.&lt;br&gt;
But for anything that needs actual adjustment, a full page feels much better.&lt;/p&gt;

&lt;p&gt;This was one of those "small feature" changes that quietly turned into a UI decision too.&lt;/p&gt;




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

&lt;p&gt;The short version: it works best when KoalaSync can access the page's video element.&lt;/p&gt;

&lt;p&gt;That covers a lot of normal browser playback: self-hosted media servers, many video sites, and some major streaming platforms.&lt;/p&gt;

&lt;p&gt;There are still edge cases. Some sites use unusual player setups, strict DRM implementations, embedded playback restrictions, or browser-specific behavior that prevents audio processing.&lt;/p&gt;

&lt;p&gt;When that happens, KoalaSync just leaves the audio unchanged instead of trying to force anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Is This Feature Finished?
&lt;/h2&gt;

&lt;p&gt;Not really.&lt;/p&gt;

&lt;p&gt;It works, and I already find it useful, but I still consider it experimental.&lt;/p&gt;

&lt;p&gt;Things I might change or add later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;better presets&lt;/li&gt;
&lt;li&gt;per-site behavior&lt;/li&gt;
&lt;li&gt;equalizer support&lt;/li&gt;
&lt;li&gt;better UI explanations&lt;/li&gt;
&lt;li&gt;maybe a simple "night mode" toggle for people who do not care about compressor settings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For now, I mostly wanted something that solves the annoying "quiet dialogue, loud explosions" problem without needing another app or external audio tool.&lt;/p&gt;




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

&lt;p&gt;KoalaSync is free and open source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Website: &lt;a href="https://sync.koalastuff.net" rel="noopener noreferrer"&gt;https://sync.koalastuff.net&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Shik3i/KoalaSync" rel="noopener noreferrer"&gt;https://github.com/Shik3i/KoalaSync&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The relevant parts are mostly in &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/content.js#L140-L229" rel="noopener noreferrer"&gt;&lt;code&gt;extension/content.js&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/Shik3i/KoalaSync/blob/main/extension/audio-options.html" rel="noopener noreferrer"&gt;&lt;code&gt;extension/audio-options.html&lt;/code&gt;&lt;/a&gt; if you want to look at the implementation.&lt;/p&gt;

&lt;p&gt;I am curious if anyone else has built audio processing features inside browser extensions before. The Web Audio API is pretty nice once it works, but browser/player edge cases are definitely a thing.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
