<?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>blog.ntcu.be</title><link>https://blog.ntcu.be/</link><description>Recent content 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 21:00:00 +0200</lastBuildDate><atom:link href="https://blog.ntcu.be/index.xml" rel="self" type="application/rss+xml"/><item><title>Nightly Cisco switch backups with AWS Lambda, S3 and Terraform</title><link>https://blog.ntcu.be/posts/cisco-switch-backups-aws-lambda/</link><pubDate>Tue, 13 Oct 2026 21:00:00 +0200</pubDate><guid>https://blog.ntcu.be/posts/cisco-switch-backups-aws-lambda/</guid><description>A serverless setup that SSHes into every office switch each night, stores the running config in S3 and emails a summary, all deployed from git.</description><content:encoded><![CDATA[<p>Every network team has the same story: a switch dies, the replacement arrives,
and then someone asks <em>&ldquo;where&rsquo;s the latest config?&rdquo;</em> Usually the answer is a
<code>.txt</code> on somebody&rsquo;s laptop from eight months ago.</p>
<p>I wanted backups that happen without anyone thinking about them, for a network
of office switches spread over a lot of sites. No extra VM to babysit, no cron
box under a desk. This is what I ended up with.</p>
<h2 id="the-big-picture">The big picture</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">EventBridge (cron) ──► Lambda (VPC) ──SSH──► switches (on-prem)
</span></span><span class="line"><span class="cl">                          │
</span></span><span class="line"><span class="cl">                          ├──► S3: configs by date / site
</span></span><span class="line"><span class="cl">                          └──► SNS: email summary
</span></span></code></pre></div><ul>
<li><strong>EventBridge</strong> fires on a schedule.</li>
<li>A <strong>Lambda function</strong> (Python + Paramiko) reads the list of switches, logs in
over SSH and grabs <code>show running-config</code> from each one, in parallel.</li>
<li>Configs land in <strong>S3</strong>, sorted by date and site.</li>
<li><strong>SNS</strong> emails a summary: which devices succeeded, which failed and why.</li>
<li>Everything is <strong>Terraform</strong>, deployed from a GitLab pipeline.</li>
</ul>
<h2 id="the-inventory-is-just-csv-files-in-git">The inventory is just CSV files in git</h2>
<p>Each site has a small semicolon-separated file: hostname and management IP.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SITE1-SW01;192.0.2.11
</span></span><span class="line"><span class="cl">SITE1-SW02;192.0.2.12
</span></span></code></pre></div><p>Terraform uploads those files to an S3 bucket with <code>aws_s3_object</code> resources.
That sounds like a detail, but it&rsquo;s my favourite part: adding a switch is a
merge request, there&rsquo;s history for free, and the bucket never drifts from
what&rsquo;s in git.</p>
<p>The site name is parsed from the hostname, so backups end up neatly organised:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">s3://&lt;backup-bucket&gt;/cisco-backups/2026-10-13/SITE1/SITE1-SW01_running_config.txt
</span></span></code></pre></div><h2 id="getting-a-lambda-onto-the-management-network">Getting a Lambda onto the management network</h2>
<p>This is where most of the actual work went. The switches live on-prem, so the
Lambda runs <strong>inside a VPC</strong> that has a route back to the on-prem network
(in my case through a Transit Gateway).</p>
<p>A Lambda in a VPC has no internet access by default, but it still needs to
talk to S3, Secrets Manager and SNS. Instead of a NAT gateway, I added VPC
endpoints:</p>
<ul>
<li><strong>S3</strong>: a <em>gateway</em> endpoint (free, just a route table entry)</li>
<li><strong>Secrets Manager</strong> and <strong>SNS</strong>: <em>interface</em> endpoints, each with its own
security group</li>
</ul>
<p>That keeps AWS API traffic inside AWS instead of hairpinning over the
on-prem link.</p>
<h2 id="talking-to-the-switches">Talking to the switches</h2>
<p>The Lambda uses Paramiko to open an interactive shell, because it needs to
handle <code>enable</code> mode:</p>
<ol>
<li>connect, wait for the login banner to finish, and read the prompt
(<code>SITE1-SW01&gt;</code> or <code>SITE1-SW01#</code>)</li>
<li>if it ends in <code>&gt;</code>, send <code>enable</code> and the enable password</li>
<li><code>terminal length 0</code> so the output isn&rsquo;t paginated</li>
<li><code>show running-config</code>, and read until <strong>that exact prompt</strong> comes back</li>
</ol>
<p>Reading until the device&rsquo;s own prompt matters: a <code>#</code> can show up anywhere in a
config (banners, <code>description Uplink #2</code>), so &ldquo;stop at the first <code>#</code>&rdquo; isn&rsquo;t
good enough. And a config is only saved if it ends with the <code>end</code> line;
anything else counts as a failure, never as a half backup.</p>
<p>Switches are processed with a <code>ThreadPoolExecutor</code> (up to 50 at a time), so a
full run finishes well within Lambda&rsquo;s 15-minute limit. 512 MB of memory is
plenty: a run over the lab switches takes about 3 seconds and uses ~110 MB.</p>
<p>Credentials live in <strong>Secrets Manager</strong>, not in the code or the Terraform: a
primary account (the normal AAA login), plus a fallback with local
credentials for when AAA is unreachable. If the primary login fails, the
fallback is tried. If the switch can&rsquo;t be reached at all, it isn&rsquo;t: other
credentials won&rsquo;t make a dead switch answer.</p>
<h2 id="one-function-several-jobs">One function, several jobs</h2>
<p>The Lambda takes its parameters from the event, so the same code covers
several schedules with a different input each time:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;aws_cloudwatch_event_target&#34; &#34;switch_backup&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  rule</span> <span class="o">=</span> <span class="k">aws_cloudwatch_event_rule</span><span class="p">.</span><span class="k">daily_2am</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="cl"><span class="n">  arn</span>  <span class="o">=</span> <span class="k">aws_lambda_function</span><span class="p">.</span><span class="k">device_backup</span><span class="p">.</span><span class="k">arn</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">  input</span> <span class="o">=</span> <span class="k">jsonencode</span><span class="p">(</span>{
</span></span><span class="line"><span class="cl"><span class="n">    csv_bucket</span>    <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">csv_bucket_name</span>
</span></span><span class="line"><span class="cl"><span class="n">    csv_prefix</span>    <span class="o">=</span> <span class="s2">&#34;switches/&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    output_prefix</span> <span class="o">=</span> <span class="s2">&#34;switch-backup&#34;</span>
</span></span><span class="line"><span class="cl">  }<span class="p">)</span>
</span></span><span class="line"><span class="cl">}
</span></span></code></pre></div><ul>
<li><strong>Switches</strong>: every night at 02:00 UTC</li>
<li><strong>Wireless LAN controllers</strong>: weekly, same function, <code>csv_prefix = &quot;WLC/&quot;</code></li>
<li><strong>&ldquo;Tech dump&rdquo;</strong>: a second Lambda that runs a list of <code>show</code> commands
(<code>show version</code>, <code>show interface status</code>, <code>show cdp neighbors</code>, &hellip;) every
Sunday. Handy as a weekly snapshot of what the network <em>looks</em> like, not just
how it&rsquo;s configured.</li>
</ul>
<h2 id="storage-that-costs-next-to-nothing">Storage that costs next to nothing</h2>
<p>Config files are tiny, but they pile up. The backup bucket uses:</p>
<ul>
<li><strong>Intelligent-Tiering archive</strong>: Archive Access after 90 days, Deep Archive
after 180</li>
<li>a <strong>lifecycle rule</strong> that deletes objects after 365 days</li>
</ul>
<h2 id="knowing-it-actually-ran">Knowing it actually ran</h2>
<p>A backup job that fails silently is worse than none, because everyone <em>thinks</em>
there are backups. So:</p>
<ul>
<li>every run emails a per-device summary through SNS, and the subject says how
many devices failed (<code>Device Backup Summary: 2 failed</code>)</li>
<li>CloudWatch alarms fire on Lambda errors, throttles and runs that get close to
the timeout</li>
<li>an alarm fires when <strong>any device</strong> fails</li>
</ul>
<p>That last one needed a trick. A run where three switches fail is still a
<em>successful</em> Lambda invocation, so the built-in <code>Errors</code> metric never sees it.
The Lambda logs its counts in
<a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format.html">Embedded Metric Format</a>,
a JSON log line that CloudWatch turns into a metric by itself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span><span class="nt">&#34;_aws&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;CloudWatchMetrics&#34;</span><span class="p">:</span> <span class="p">[{</span><span class="nt">&#34;Namespace&#34;</span><span class="p">:</span> <span class="s2">&#34;NetworkBackup&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;Dimensions&#34;</span><span class="p">:</span> <span class="p">[[</span><span class="s2">&#34;FunctionName&#34;</span><span class="p">]],</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;Metrics&#34;</span><span class="p">:</span> <span class="p">[{</span><span class="nt">&#34;Name&#34;</span><span class="p">:</span> <span class="s2">&#34;FailedDevices&#34;</span><span class="p">,</span> <span class="nt">&#34;Unit&#34;</span><span class="p">:</span> <span class="s2">&#34;Count&#34;</span><span class="p">}]}]},</span>
</span></span><span class="line"><span class="cl"> <span class="nt">&#34;FunctionName&#34;</span><span class="p">:</span> <span class="s2">&#34;device-backup&#34;</span><span class="p">,</span> <span class="nt">&#34;FailedDevices&#34;</span><span class="p">:</span> <span class="mi">0</span><span class="p">}</span>
</span></span></code></pre></div><p>No <code>PutMetricData</code> call, no extra IAM permission, and no extra VPC endpoint
for a Lambda that has no internet access.</p>
<h2 id="deploying-it-gitlab-ci-with-oidc">Deploying it: GitLab CI with OIDC</h2>
<table>
	<thead>
			<tr>
					<th>Stage</th>
					<th>What happens</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>test</td>
					<td>unit tests against a fake Cisco switch</td>
			</tr>
			<tr>
					<td>validate</td>
					<td><code>terraform validate</code></td>
			</tr>
			<tr>
					<td>plan</td>
					<td><code>terraform plan</code>, which also builds the Lambda ZIPs</td>
			</tr>
			<tr>
					<td>deploy</td>
					<td><code>terraform apply</code>, <strong>only on <code>main</code></strong></td>
			</tr>
	</tbody>
</table>
<p>GitLab authenticates to AWS with <strong>OIDC</strong>: the pipeline assumes an IAM role
through a short-lived token. No long-lived AWS access keys stored anywhere.
State lives in GitLab&rsquo;s managed Terraform state, with locking.</p>
<p>The tests run against a tiny fake switch: an SSH server written with Paramiko
that mimics IOS (<code>&gt;</code> and <code>#</code> prompts, <code>enable</code>, a banner full of <code>#</code> characters,
and output that trickles in slowly like over a WAN link). It&rsquo;s about 180 lines
and makes it easy to test the SSH logic without touching a real network.</p>
<h2 id="a-few-cicd-details-worth-copying">A few CI/CD details worth copying</h2>
<ul>
<li>
<p><strong>Build the Lambda ZIPs from source in Terraform</strong> (<code>archive_file</code>) instead
of committing them, so what&rsquo;s deployed is always what&rsquo;s in git. The ZIP is
created during <code>plan</code> and the separate <code>apply</code> job needs that file, so pass
it along as a CI artifact.</p>
</li>
<li>
<p><strong>Only deploy from <code>main</code>.</strong> Merge requests get tests and a <code>plan</code> they can
review, nothing more.</p>
</li>
<li>
<p><strong>Pin the AWS account.</strong> One line in the provider makes Terraform refuse to
run with credentials for any other account:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">provider</span> <span class="s2">&#34;aws&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  region</span>              <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">aws_region</span>
</span></span><span class="line"><span class="cl"><span class="n">  allowed_account_ids</span> <span class="o">=</span> <span class="p">[</span><span class="k">var</span><span class="p">.</span><span class="k">aws_account_id</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">}
</span></span></code></pre></div></li>
<li>
<p><strong>Know where your CI variables come from.</strong> GitLab variables can be set on
the project <em>and</em> inherited from parent groups, and <em>protected</em> variables only
exist on protected branches. Worth checking which <code>AWS_ROLE_ARN</code> a branch
pipeline actually gets.</p>
</li>
</ul>
<h2 id="whats-next">What&rsquo;s next</h2>
<ul>
<li><strong>Netmiko instead of raw Paramiko.</strong> The prompt handling works well, but
Netmiko already knows the quirks of every Cisco platform.</li>
<li><strong>Diff the configs.</strong> Every night is a full copy. Committing them to a git
repo (the way Oxidized or RANCID do) would give you a &ldquo;what changed last
night?&rdquo; email for free.</li>
</ul>
<p>All in all it&rsquo;s a handful of Terraform files and two Python functions, it costs
pennies a month, and nobody has had to think about switch backups since.</p>
]]></content:encoded></item><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><item><title>Hello, world</title><link>https://blog.ntcu.be/posts/hello-world/</link><pubDate>Tue, 13 Oct 2026 14:00:00 +0200</pubDate><guid>https://blog.ntcu.be/posts/hello-world/</guid><description>The obligatory first post.</description><content:encoded>&lt;p>Welcome to ntcu.be. This is where I&amp;rsquo;ll write about my homelab, networking and
the things I self-host. More soon.&lt;/p>
</content:encoded></item><item><title>About</title><link>https://blog.ntcu.be/about/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://blog.ntcu.be/about/</guid><description>&lt;p&gt;Hi, I&amp;rsquo;m Jens. I run a small homelab and write here about networking,
self-hosting and whatever I&amp;rsquo;m currently tinkering with.&lt;/p&gt;
&lt;p&gt;The name? &lt;em&gt;ntcu.be&lt;/em&gt; is short for &lt;strong&gt;Netcue&lt;/strong&gt;, my company: drop the E&amp;rsquo;s, add
the Belgian &lt;code&gt;.be&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This site is built with &lt;a href="https://gohugo.io"&gt;Hugo&lt;/a&gt;, stored in my own
&lt;a href="https://forgejo.org"&gt;Forgejo&lt;/a&gt; instance and deployed automatically on every push.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Hi, I&rsquo;m Jens. I run a small homelab and write here about networking,
self-hosting and whatever I&rsquo;m currently tinkering with.</p>
<p>The name? <em>ntcu.be</em> is short for <strong>Netcue</strong>, my company: drop the E&rsquo;s, add
the Belgian <code>.be</code>.</p>
<p>This site is built with <a href="https://gohugo.io">Hugo</a>, stored in my own
<a href="https://forgejo.org">Forgejo</a> instance and deployed automatically on every push.</p>
]]></content:encoded></item></channel></rss>