<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Docker on blog.ntcu.be</title><link>https://blog.ntcu.be/tags/docker/</link><description>Recent content in Docker on blog.ntcu.be</description><image><title>blog.ntcu.be</title><url>https://blog.ntcu.be/og-image.png</url><link>https://blog.ntcu.be/og-image.png</link></image><generator>Hugo</generator><language>en-us</language><copyright>Jens · RSS</copyright><lastBuildDate>Tue, 13 Oct 2026 20:55:00 +0200</lastBuildDate><atom:link href="https://blog.ntcu.be/tags/docker/index.xml" rel="self" type="application/rss+xml"/><item><title>Building a self-hosted password generator with one-time secret links</title><link>https://blog.ntcu.be/posts/password-generator-secret-sharing/</link><pubDate>Tue, 13 Oct 2026 20:55:00 +0200</pubDate><guid>https://blog.ntcu.be/posts/password-generator-secret-sharing/</guid><description>A small Docker app that generates strong passwords and shares them through links that expire or self-destruct. Secrets are encrypted in the browser, so the server can&amp;rsquo;t read them.</description><content:encoded><![CDATA[<p>Sending a password over email or Teams means it sits in someone&rsquo;s mailbox (and
a backup, and a search index) forever. I wanted something simple to hand
credentials to customers and colleagues: generate a strong password, get a link,
and have that link stop working after it&rsquo;s been read.</p>
<p>So I built one. You can try it at
<strong><a href="https://password.ntcu.be/">password.ntcu.be</a></strong>.</p>
<h2 id="what-it-does">What it does</h2>
<ul>
<li><strong>Generates passwords in the browser</strong> with <code>crypto.getRandomValues()</code>.
You pick the length and character sets; the generated password never touches
the server unless you choose to share it.</li>
<li><strong>Shares a secret as a link</strong>, with either:
<ul>
<li>an expiry time,</li>
<li><strong>one-time</strong> access (deleted after the first read), or</li>
<li>a <strong>view limit</strong> (1 to 50 views).</li>
</ul>
</li>
<li><strong>Encrypts in the browser.</strong> The server only ever sees ciphertext; the key
lives in the link itself.</li>
<li><strong>Custom branding</strong> through environment variables (logo, colours, title), so
it can look like your company&rsquo;s tool.</li>
</ul>
<h2 id="architecture">Architecture</h2>
<p>Three containers in a Docker Compose stack:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">internet ──► nginx ──► app (Node.js / Express) ──► Redis
</span></span><span class="line"><span class="cl">            (public)   (internal network only)     (internal network only)
</span></span></code></pre></div><p>The <code>app</code> and <code>redis</code> containers sit on a Docker network marked
<code>internal: true</code>, so they have no route to the outside world at all. Only nginx
is exposed.</p>
<h2 id="encryption-the-server-cant-read-your-secrets">Encryption: the server can&rsquo;t read your secrets</h2>
<p>The secret is encrypted <strong>in the browser</strong>, with the Web Crypto API, before
anything is sent. Each secret gets its own random 256-bit AES-GCM key:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">encryptForLink</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">keyBytes</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">getRandomValues</span><span class="p">(</span><span class="k">new</span> <span class="nx">Uint8Array</span><span class="p">(</span><span class="mi">32</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">iv</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">getRandomValues</span><span class="p">(</span><span class="k">new</span> <span class="nx">Uint8Array</span><span class="p">(</span><span class="mi">12</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">key</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">subtle</span><span class="p">.</span><span class="nx">importKey</span><span class="p">(</span><span class="s2">&#34;raw&#34;</span><span class="p">,</span> <span class="nx">keyBytes</span><span class="p">,</span> <span class="s2">&#34;AES-GCM&#34;</span><span class="p">,</span> <span class="kc">false</span><span class="p">,</span> <span class="p">[</span><span class="s2">&#34;encrypt&#34;</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">subtle</span><span class="p">.</span><span class="nx">encrypt</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nx">name</span><span class="o">:</span> <span class="s2">&#34;AES-GCM&#34;</span><span class="p">,</span> <span class="nx">iv</span><span class="p">,</span> <span class="nx">additionalData</span><span class="o">:</span> <span class="nx">AAD</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nx">key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="k">new</span> <span class="nx">TextEncoder</span><span class="p">().</span><span class="nx">encode</span><span class="p">(</span><span class="nx">plaintext</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">ciphertext</span><span class="o">:</span> <span class="p">{</span> <span class="nx">v</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nx">iv</span><span class="o">:</span> <span class="nx">toBase64Url</span><span class="p">(</span><span class="nx">iv</span><span class="p">),</span> <span class="nx">data</span><span class="o">:</span> <span class="nx">toBase64Url</span><span class="p">(</span><span class="k">new</span> <span class="nx">Uint8Array</span><span class="p">(</span><span class="nx">data</span><span class="p">))</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nx">linkKey</span><span class="o">:</span> <span class="nx">toBase64Url</span><span class="p">(</span><span class="nx">keyBytes</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></div><p>Only the ciphertext goes to the server. The key goes into the share link, after
the <code>#</code>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://password.ntcu.be/3f2a…c91#k3Y9xQ…
</span></span><span class="line"><span class="cl">                         └─ id ─┘ └─ key: never sent to any server
</span></span></code></pre></div><p>Browsers never send the part after <code>#</code> (the <em>fragment</em>) in a request. Not to my
server, not to Cloudflare in front of it, not to the reverse proxy, and it
doesn&rsquo;t end up in any access log. The recipient&rsquo;s browser fetches the
ciphertext by id, takes the key from its own address bar and decrypts locally.
After a successful reveal, the key is removed from the address bar.</p>
<p>The server adds its own AES-256-GCM layer on top before storing it in Redis,
so a stolen Redis dump is double-encrypted noise.</p>
<p>The one thing this can&rsquo;t protect against is a compromised server serving
<em>different JavaScript</em>. That&rsquo;s true for every web-based tool like this
(PrivateBin, Yopass, Bitwarden Send). A strict Content-Security-Policy and
zero third-party scripts keep that surface small.</p>
<h2 id="click-to-reveal">Click to reveal</h2>
<p>Opening a link doesn&rsquo;t fetch anything. The recipient sees <em>&ldquo;Someone shared a
secret with you&rdquo;</em> and a <strong>Reveal secret</strong> button. That matters more than it
sounds: Outlook Safe Links, Teams and Slack previews &ldquo;visit&rdquo; links before a
human does. If opening the page fetched the secret, a one-time link would be
burned by a bot before the real recipient ever saw it.</p>
<h2 id="let-redis-do-the-expiring">Let Redis do the expiring</h2>
<p>Every secret is stored with Redis&rsquo;s <code>EX</code> option, so expiry isn&rsquo;t something my
code has to remember to clean up: when the TTL runs out, the key is simply gone.</p>
<ul>
<li><strong>One-time</strong> secrets are deleted as part of the read itself.</li>
<li><strong>View-limited</strong> secrets carry a counter; each read decrements it (keeping
the remaining TTL), and the last read deletes the key.</li>
</ul>
<p>Reading and consuming a view happen in one small Lua script, which Redis runs
atomically. Two requests hitting a one-time link at the same moment can&rsquo;t both
get the secret: fire 10 in parallel and you get exactly one <code>200</code> and nine
<code>404</code>s.</p>
<p>Persistence is optional. With AOF enabled, secrets survive a container restart.
With it disabled, a restart wipes everything, which for some people is a
feature.</p>
<h2 id="hardening">Hardening</h2>
<ul>
<li><strong>Two layers of rate limiting</strong> per client IP: nginx (<code>limit_req</code>, 10
requests/s with a small burst, answering <code>429</code>) in front of
<code>express-rate-limit</code> in the app. The request path is Cloudflare → Nginx Proxy
Manager → the app&rsquo;s nginx, so nginx&rsquo;s <code>realip</code> module recovers the visitor&rsquo;s
IP from <code>X-Forwarded-For</code>, trusting only private addresses and
<a href="https://www.cloudflare.com/ips/">Cloudflare&rsquo;s ranges</a>. Otherwise every
visitor would share one rate-limit bucket.</li>
<li><strong>Strict headers</strong>: Helmet plus nginx set a tight Content-Security-Policy (no
inline scripts, no framing), <code>nosniff</code>, <code>no-referrer</code>.</li>
<li><strong><code>Cache-Control: no-store</code></strong> on secret responses, so they don&rsquo;t end up in a
browser or proxy cache.</li>
<li><strong>Boring 404s</strong>: secret IDs are UUIDv4 and validated; invalid, expired and
already-read secrets all return the same <code>404 Not found</code>.</li>
<li><strong>Input limits</strong>: 4096 characters per secret, 1 MB request bodies, expiry
capped at 30 days.</li>
</ul>
<h2 id="deployment">Deployment</h2>
<p>It runs on my homelab Docker host and deploys with the same push-to-deploy
pipeline as everything else (see
<a href="https://blog.ntcu.be/posts/self-hosted-git-and-ci/">Self-hosting my code</a>): a push to <code>main</code>
builds the image on my runner, pushes it to my Forgejo registry and the Docker
host pulls it.</p>
<h2 id="whats-next">What&rsquo;s next</h2>
<ul>
<li><strong>An optional passphrase</strong>, sent through a second channel (SMS, a call), so
a leaked link alone isn&rsquo;t enough. The ciphertext already carries a version
number, so this can be added without breaking existing links.</li>
</ul>
<p>It&rsquo;s a small project, but it replaced a lot of &ldquo;I&rsquo;ll send the password in a
separate email&rdquo; moments.</p>
]]></content:encoded></item><item><title>Self-hosting my code: Forgejo, runners and push-to-deploy</title><link>https://blog.ntcu.be/posts/self-hosted-git-and-ci/</link><pubDate>Tue, 13 Oct 2026 15:00:00 +0200</pubDate><guid>https://blog.ntcu.be/posts/self-hosted-git-and-ci/</guid><description>How I moved my apps off GitHub to a self-hosted Forgejo with my own CI runner, a private container registry and push-to-deploy.</description><content:encoded><![CDATA[<p>For a long time my side projects were deployed the classic homelab way: SSH into
the Docker host, <code>git pull</code> (if the project was even in git), <code>docker compose up -d --build</code> and hope for the best. One of my apps turned out to exist <em>only</em> on
that Docker host. Time to fix that.</p>
<h2 id="the-goal">The goal</h2>
<ul>
<li>Git hosting I control, on my own hardware</li>
<li>CI runners I host myself</li>
<li><code>git push</code> to <code>main</code> = the change is live a minute later</li>
<li>Easy rollback when something breaks</li>
</ul>
<h2 id="the-pieces">The pieces</h2>
<p><strong>Forgejo</strong> is the git server. It&rsquo;s a lightweight, community-run fork of Gitea
and it runs comfortably in a small Proxmox LXC container (2 cores, 2 GB RAM).
It brings repositories, pull requests, a container registry and <strong>Forgejo
Actions</strong>, which uses (almost) the same workflow syntax as GitHub Actions.</p>
<p><strong>A dedicated runner VM</strong> picks up the jobs. It has more CPU and memory than the
Docker host, so all the image building happens there, with a warm build cache.</p>
<p><strong>The Docker host</strong> no longer builds anything. It just pulls images and
restarts containers.</p>
<h2 id="how-a-deploy-works">How a deploy works</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">git push ──► runner builds image ──► Forgejo registry ──► Docker host pulls + restarts
</span></span><span class="line"><span class="cl">             (tagged with the commit)
</span></span></code></pre></div><ol>
<li>Every repo has a <code>.forgejo/workflows/deploy.yml</code> with two jobs: <code>build</code> and <code>deploy</code>.</li>
<li><code>build</code> builds the images and pushes them to the Forgejo registry, tagged with
the full commit SHA.</li>
<li><code>deploy</code> SSHes into the Docker host with a key that can do exactly one thing:
run a small deploy script. That script checks out that exact commit and runs
the repo&rsquo;s <code>ci/deploy.sh</code>, which pulls the images and restarts the containers.</li>
</ol>
<p>Because every image is tagged with its commit, <strong>rolling back is just re-running
an older pipeline</strong>.</p>
<h2 id="locking-it-down">Locking it down</h2>
<ul>
<li>The deploy key is restricted with <code>command=</code> and <code>from=</code> in <code>authorized_keys</code>:
it only works from the runner&rsquo;s IP and can only run the deploy script.</li>
<li>Secrets (<code>.env</code> files) never go into git; they only live on the Docker host.</li>
<li>The registry push token can only write packages; the Docker host has a
read-only token.</li>
<li>Old images are cleaned up automatically by a registry cleanup rule.</li>
</ul>
<h2 id="whats-next">What&rsquo;s next</h2>
<ul>
<li>Tests that must pass before anything deploys</li>
<li>A health check after deploy with automatic rollback</li>
<li>Automatic dependency update PRs</li>
</ul>
<p>And this blog? It&rsquo;s a Hugo site in the same Forgejo, deployed by the same
pipeline. Writing this post was <code>git commit &amp;&amp; git push</code>.</p>
]]></content:encoded></item></channel></rss>