<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>upmann.dev</title><description>Field notes from building, automating, and maintaining self-hosted infrastructure.</description><link>https://upmann.dev/</link><language>en-us</language><item><title>Component Demo</title><link>https://upmann.dev/blog/component-demo/</link><guid isPermaLink="true">https://upmann.dev/blog/component-demo/</guid><description>A live demo of Keystatic-supported MDX components, including structured tabs, layouts, media, code blocks, timelines, and callouts.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>This post exercises the structured, editor-supported MDX APIs. Use it as a
round-trip fixture and a visual reference for component props and authored
children.

## Admonitions

  This is an **info** admonition. Use it for helpful context, tips, and neutral
  information the reader should be aware of.

  This is a **warning** admonition. Use it when something could go wrong, or
  when the reader needs to proceed with caution.

  This is a **danger** admonition. Use it for destructive actions, breaking
  changes, or things that will cause problems if ignored.

  This is a **success** admonition. Use it to confirm something worked, or to
  highlight a positive outcome.

## Steps

**Create the component.** Add a new `.astro` file in `src/components/`
with the component&apos;s template and logic.

**Define the Props interface.** Export a TypeScript interface so the
component is type-safe when used in MDX.

**Wire it into Keystatic.** Create a matching component block in the
Keystatic admin project so the editor knows about its schema.

**Use it in content.** Insert the component without a manual import;
Astro&apos;s manifest-backed MDX pipeline supplies editor-supported imports.

## Accordion

Keystatic is a CMS that treats your content as files on disk — Markdown, MDX,
YAML, JSON — rather than rows in a database. You get a rich editing UI that
writes directly to your content repo, with no API layer between the editor
and your files.

MDX lets you import and use components directly in your Markdown files. This
means you can create rich, interactive content — tabs, accordions, code
comparisons, terminal windows — without leaving the Markdown authoring
experience.

Create a component block definition in the Keystatic admin project (one file
per component, ~20 lines each), add it to the `mdxComponents` map, rebuild,
and it appears in the editor toolbar immediately.

## Tabs

```bash
npm install @keystatic/core
```

```bash
pnpm add @keystatic/core
```

```bash
yarn add @keystatic/core
```

## Pros &amp; Cons

## Side by Side

The **SideBySide** component puts text alongside media. On desktop, the two
panels sit next to each other. On mobile, they stack vertically — no media
query needed, it&apos;s built into the component.

![A tidy workspace with a laptop and monitor](https://images.unsplash.com/photo-1498050108023-c5249f4df085?w=600&amp;h=400&amp;fit=crop)

With the **reverse** prop, the media appears on the left and the text on the
right. This is useful for alternating layouts in a long article — it keeps the
visual rhythm from becoming monotonous.

![Code on a dark-themed editor](https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=600&amp;h=400&amp;fit=crop)

## Image Grid

![Server rack with blinking lights](https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=400&amp;h=300&amp;fit=crop)

![Network switch and cables](https://images.unsplash.com/photo-1614624532983-4ce03382d63d?w=400&amp;h=300&amp;fit=crop)

![Raspberry Pi cluster](https://images.unsplash.com/photo-1604328698692-f76ea9498e7d?w=400&amp;h=300&amp;fit=crop)

## Figure

  ![Laptop showing code beside a notebook](https://images.unsplash.com/photo-1516321318423-f06f85e504b3?w=800&amp;h=450&amp;fit=crop)

## File Tree

## Keyboard Input

Press Ctrl + K to open search, then press
Escape to close it.

## Responsive Table

| Component | Authoring shape | No-JS behavior |
| --- | --- | --- |
| Tabs | Repeating TabItem children | Labeled stacked sections |
| Timeline | Dated TimelineItem children | Readable ordered history |

## Content Grid

  
    
  
  
    
      Grid items can contain editor-supported components.
    
  
  
    
  
  
    A two-column spanning item demonstrates responsive span behavior without
    exposing arbitrary CSS classes to editors.
  
  
    Full-width content remains readable when the grid collapses on narrow
    screens.
  

## API Endpoint

## Terminal Window

## Code Compare

## Case Study Header

## Video Embed

## GitHub Repo Card

## Pull Quote

The best code is the code you don&apos;t write. Every line you don&apos;t write is a
line you don&apos;t have to debug, test, or maintain.

## Stat Blocks

## Link Card

## Aside

**Did you know?** Keystatic stores content as plain files on disk — Markdown,
MDX, YAML, JSON. No database. Your content lives in your git repo alongside
your code, which means you get version history, branching, and PR reviews for
free.

## Timeline

First commit. A single Ubuntu server running Docker Compose with Traefik as a
reverse proxy.

Added Ansible for configuration management. Playbooks replaced shell scripts.

Migrated to a 3-node k3s cluster. GitOps with Flux, cert-manager for TLS,
Longhorn for storage.

Added monitoring with Prometheus + Grafana. Alerting via ntfy.sh push
notifications.

Keystatic admin panel deployed. Content authoring moved from hand-writing MDX
to a rich editing UI.

## Carousel

![Desk setup with monitor and keyboard](https://images.unsplash.com/photo-1593642632559-0c6d3fc62b89?w=600&amp;h=400&amp;fit=crop)

![Mechanical keyboard close-up](https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?w=600&amp;h=400&amp;fit=crop)

![Desk plant and coffee](https://images.unsplash.com/photo-1544816155-12df9643f363?w=600&amp;h=400&amp;fit=crop)

## Animate

This paragraph slides up into view when you scroll to it. The **Animate**
component supports presets like `fade`, `slide-up`, `zoom-focus`, and
`stagger-grid`. Apply it around any content you want to reveal on scroll.

A second animation block with a **fade** effect and a longer delay. Multiple
`Animate` blocks can be chained to create sequenced reveals down the page.

## Reveal

The **Reveal** component is a lighter alternative — pure CSS scroll-driven
animation with no JavaScript. Use it for subtle content reveals that don&apos;t
need the full motion engine.

Includes four variants: `fade-up`, `fade-in`, `slide-left`, and `slide-right`.
Like Animate, it respects `prefers-reduced-motion` automatically.</content:encoded><category>meta</category><category>demo</category></item><item><title>TypeScript Strict Mode: The Bugs It Catches Later</title><link>https://upmann.dev/blog/typescript-strict-mode/</link><guid isPermaLink="true">https://upmann.dev/blog/typescript-strict-mode/</guid><description>Strict mode is easy to enable and painful to retroactively satisfy. Here are the patterns that make it worth the upfront cost.</description><pubDate>Sat, 15 Jun 2024 00:00:00 GMT</pubDate><content:encoded>`&quot;strict&quot;: true` in `tsconfig.json` enables eight compiler checks at once.
The first six catch bugs immediately. The last two — `noImplicitAny` and
`strictNullChecks` — catch bugs six months later, when you&apos;d otherwise be
debugging production at 11pm.

## The Two That Matter Most

### `noImplicitAny`

Without this, TypeScript silently annotates anything it can&apos;t infer as
`any`. With it, you&apos;re forced to either annotate explicitly or accept the
annotation that the type was unknowable.

The function that bites:

```ts
function parseConfig(raw) {
  // What is `raw`? string? object? Buffer? `any` by default.
  return JSON.parse(raw);
}
```

With `noImplicitAny`, you must decide. The decision is the value.

### `strictNullChecks`

Without it, `null` and `undefined` flow freely through every type. With it,
a type `T` does not include `null` unless you write `T | null`.

```ts
function findUser(id: string): User | undefined {
  // ...
}

const user = findUser(&quot;abc&quot;);
user.name; // error: &apos;user&apos; is possibly &apos;undefined&apos;
```

The `possibly undefined` error feels pedantic until the day a database
query returns null and your code throws `Cannot read property &apos;name&apos; of
undefined`.

## Patterns That Make Strict Bearable

**Narrow with type guards, not casts.**

```ts
// Bad — silences the compiler, doesn&apos;t actually check
const name = (user as User).name;

// Good — narrows the type, runs at runtime
if (user) {
  const name = user.name;
}
```

**Use the exhaustive switch for discriminated unions.**

```ts
type Result = { ok: true; value: number } | { ok: false; error: string };

function handle(r: Result) {
  switch (r.ok) {
    case true:
      return r.value;
    case false:
      return r.error;
    default:
      const _exhaustive: never = r;
      return _exhaustive;
  }
}
```

The `_exhaustive: never` line gives you a compile-time error if you ever
add a new variant to `Result` without updating `handle`. This is the single
best TypeScript feature, and it only works under strict mode.

**Don&apos;t reach for `!` (non-null assertion).**

`user!.name` says &quot;I know better than the compiler.&quot; Sometimes you do. More
often, you&apos;ll regret it. Prefer:

```ts
if (user === undefined) throw new Error(&quot;user must exist&quot;);
user.name; // now safe
```

The explicit throw is debuggable. The non-null assertion isn&apos;t.

**Avoid `as` entirely when possible.**

Type assertions are an escape hatch, not a tool. If you need to cast, ask
why the type isn&apos;t what you expect — usually the fix is upstream.

## The Migration Tax

Enabling strict on an existing codebase is brutal. The strategy:

1. Turn it on, accept the wall of errors.
2. Pick a directory. Fix it. Commit.
3. Repeat.

Don&apos;t try to land strict mode in one PR. The cognitive load is too high
and the review is meaningless.

## Is It Worth It?

Every time. The bugs strict mode catches are the ones that take down
production: undefined access on optional data, implicit any flowing
through a hot path, a discriminated union that grew a new case nobody
handled. The upfront tax is real; the late-stage payoff is larger.</content:encoded><category>typescript</category><category>web</category></item><item><title>Astro 7: What Changed and What to Use</title><link>https://upmann.dev/blog/astro-7-changes/</link><guid isPermaLink="true">https://upmann.dev/blog/astro-7-changes/</guid><description>A tour of the Astro 7 features that actually affect day-to-day work — Content Layer API, font management, ClientRouter, and the Rolldown migration.</description><pubDate>Wed, 22 May 2024 00:00:00 GMT</pubDate><content:encoded>Astro 7 lands with a new bundler (Rolldown), a stable Content Layer API, and
several deprecations that have caught me out. Here&apos;s what actually changes
in practice.

## Content Layer API: `content.config.ts` + Loaders

The old `src/content/config.ts` file is gone in favour of
`src/content.config.ts` (note the leading dot). Collections now use a
`loader` from `astro/loaders`:

```ts

const blog = defineCollection({
  loader: glob({ pattern: &quot;**/*.{md,mdx}&quot;, base: &quot;./src/content/blog&quot; }),
  schema: z.object({ /* ... */ }),
});
```

Three things to internalise:

1. **Import Zod from `astro/zod`**, not standalone `zod`. Astro re-exports
   a version that&apos;s compatible with its content layer.
2. **`post.id` includes the directory prefix** when using the glob loader.
   A post at `src/content/blog/en/hello.mdx` has id `en/hello`, not `hello`.
   Strip the locale prefix before using it as a URL slug.
3. **`Astro.glob()` is gone.** Use `getCollection()` or `import.meta.glob`.

## The Fonts API

Manual `@font-face` declarations are out. Configure fonts in
`astro.config.mjs`:

```js

export default defineConfig({
  fonts: [
    {
      provider: fontProviders.local(),
      name: &quot;Satoshi&quot;,
      cssVariable: &quot;--font-display&quot;,
      options: { variants: [/* ... */] },
    },
  ],
});
```

Astro handles the `@font-face` generation, the variable wiring, and the
preload hints. You reference the font via the CSS variable in your Tailwind
theme tokens.

## View Transitions: ``

`` is removed. Import `` from
`astro:transitions` instead:

```astro
---

---
&lt;head&gt;
  
&lt;/head&gt;
```

The lifecycle events renamed accordingly: `astro:page-load` replaces
`astro:after-swap` for &quot;navigation completed&quot; hooks (both fire, but
`astro:page-load` is the one you want for re-initialising client-side
state).

## Tailwind 4: The Vite Plugin

`@astrojs/tailwind` is deprecated. Use the Vite plugin directly:

```js

export default defineConfig({
  vite: { plugins: [tailwindcss()] },
});
```

Dark mode variants move into `global.css`:

```css
@custom-variant dark (&amp;:where(.dark, .dark *));
```

## The Rolldown Migration

Astro 7 ships Rolldown (a Rust-backed Rollup replacement) as the default
bundler. In practice this means faster builds and smaller outputs, but a
few integrations haven&apos;t caught up:

- Some Vite plugins that depend on Rollup internals break.
- PWA integrations that hook into the build graph may need updates.

When an integration breaks, the symptom is usually a build-time error about
a missing export or a worker resolution failure. File an issue and pin the
 Astro version in the meantime.

## Container API

The Container API (introduced experimentally in 5.x) is stable. Import
renderers from the container-renderer subpath:

```ts

```

This is what powers component unit tests.

## Verdict

Astro 7&apos;s headline is performance, but the day-to-day wins are the Content
Layer API and the deprecation cleanup. The migration is mostly mechanical —
search-and-replace `Astro.glob`, swap `ViewTransitions`, fix the Tailwind
plugin — and the result is a faster, more consistent codebase.</content:encoded><category>astro</category><category>typescript</category><category>web</category></item><item><title>Ansible: From Ad-Hoc Commands to Roles</title><link>https://upmann.dev/blog/ansible-roles-journey/</link><guid isPermaLink="true">https://upmann.dev/blog/ansible-roles-journey/</guid><description>How to structure Ansible as your homelab grows — ad-hoc commands, then playbooks, then roles, then collections. With signs you&apos;ve outgrown each stage.</description><pubDate>Thu, 18 Apr 2024 00:00:00 GMT</pubDate><content:encoded>Every Ansible journey passes through the same four stages. Recognising where
you are keeps the tooling from becoming its own burden.

## Stage 1: Ad-Hoc Commands

You have one server and one task. Use `ansible` directly:

```bash
ansible all -i host, -m apt -a &quot;name=htop state=present&quot; --become
```

Note the trailing comma after the host — that&apos;s how you tell Ansible the
inventory is a single host rather than a filename. This is the kind of detail
that costs you twenty minutes the first time.

## Stage 2: Playbooks

The third time you ran the same ad-hoc command, it became a playbook.

```yaml
- hosts: all
  become: true
  tasks:
    - name: Install baseline packages
      ansible.builtin.apt:
        name:
          - htop
          - tmux
          - git
        state: present
```

Run it with `ansible-playbook -i inventory.ini site.yml`. At this stage a
single `site.yml` is fine.

## Stage 3: Roles

The signs you&apos;ve outgrown a monolithic playbook:

- The file is longer than 200 lines.
- You have `when: inventory_hostname == ...` checks.
- You&apos;re copy-pasting task blocks between playbooks.

Roles give you structure: `tasks/`, `handlers/`, `defaults/`, `vars/`,
`templates/`, `files/`. Create them with `ansible-galaxy init` rather than by
hand — the directory structure matters.

```
roles/
  baseline/
    tasks/main.yml
    defaults/main.yml
```

Then in your playbook:

```yaml
- hosts: all
  roles:
    - baseline
    - docker
```

The key insight: `defaults/` is for variables the role author thinks are
reasonable; `vars/` is for variables the role author thinks are required.
External callers override `defaults/`, not `vars/`.

## Stage 4: Collections

When you&apos;re publishing roles or sharing them between homelabs, collections
are the unit of distribution. A collection packages roles, modules,
playbooks, and plugins under a namespace:

```bash
ansible-galaxy collection init homelab.base
```

Collections are what you install with `ansible-galaxy collection install`.
The format is `namespace.collection`. If you ever publish to Galaxy, this is
the contract.

## Practical Tips

**Idempotency first.** Every task should be safe to run twice. The built-in
modules handle this; raw `command:` calls usually don&apos;t. Prefer the
specialised module.

**Templates over lineinfile.** `ansible.builtin.lineinfile` works for a
single line. For anything more complex, ship a template — debugging regex
substitutions at 2am is no fun.

**Test with Molecule early.** Molecule is Ansible&apos;s test harness. It&apos;s
opinionated about role structure, so adopt it when you hit Stage 3 — earlier
is painful because there&apos;s no role to test.

**Tag everything.** Tags let you run a subset of a playbook:

```yaml
- name: Install packages
  ansible.builtin.apt: ...
  tags: [packages]
```

`ansible-playbook --tags packages` skips everything else. Indispensable for
iteration.

## Wherever You Stop

Plenty of homelabs never need Stage 4. That&apos;s fine. The trap is jumping
stages because the docs make it look impressive — you end up maintaining a
collection structure for ten servers and four roles. Match the tooling to
the actual complexity.</content:encoded><category>ansible</category><category>automation</category><category>homelab</category></item><item><title>Traefik Labels: A Practical Reference</title><link>https://upmann.dev/blog/traefik-labels-reference/</link><guid isPermaLink="true">https://upmann.dev/blog/traefik-labels-reference/</guid><description>Traefik configuration via Docker labels, demystified — routers, services, middlewares, and TLS resolvers with copy-paste examples.</description><pubDate>Mon, 04 Mar 2024 00:00:00 GMT</pubDate><content:encoded>Traefik&apos;s label-based configuration is powerful but terse. The official docs
are comprehensive; this post is the cheat sheet I wish I&apos;d had when I started.

## The Mental Model

Three concepts:

- **Routers** match incoming requests (by Host, Path, etc.) and route them.
- **Services** forward traffic to a backend container&apos;s port.
- **Middlewares** transform requests/responses on the way through.

Every label is namespaced under one of these. The pattern is:

```
traefik.&lt;http&gt;.&lt;routers|services|middlewares&gt;.&lt;name&gt;.&lt;option&gt;=&lt;value&gt;
```

## A Minimal HTTPS Service

```yaml
labels:
  - &quot;traefik.enable=true&quot;
  - &quot;traefik.docker.network=traefik&quot;
  - &quot;traefik.http.routers.myapp.rule=Host(`app.example.com`)&quot;
  - &quot;traefik.http.routers.myapp.entrypoints=websecure&quot;
  - &quot;traefik.http.routers.myapp.tls.certresolver=le&quot;
  - &quot;traefik.http.services.myapp.loadbalancer.server.port=8080&quot;
networks:
  traefik:
    external: true
```

That&apos;s the irreducible HTTPS service. Six lines of labels, one network, one
port. Don&apos;t add anything else until you need it.

## Entrypoints and Cert Resolvers

`websecure` is conventionally the HTTPS entrypoint (port 443). `le` is
typically a Let&apos;s Encrypt ACME resolver defined in Traefik&apos;s static config.
If you&apos;re using a self-signed cert for development, drop the `certresolver`
line and add `tls: true` to the router instead.

## Middlewares: Compose, Don&apos;t Duplicate

Middlewares are reusable. Define a CORS middleware once and reference it from
many routers:

```yaml
- &quot;traefik.http.middlewares.cors.headers.accessControlAllowOriginList=https://app.example.com&quot;
- &quot;traefik.http.middlewares.cors.headers.accessControlAllowMethods=GET,POST,PUT&quot;
- &quot;traefik.http.middlewares.cors.headers.addVaryHeader=true&quot;
```

Attach it with a comma-separated list:

```yaml
- &quot;traefik.http.routers.myapp.middlewares=cors@docker&quot;
```

The `@docker` suffix disambiguates the source provider. It&apos;s optional in
most setups but makes logs easier to read.

## Common Pitfalls

**Forgetting the network.** Traefik can only route to containers on a network
it shares. The `traefik.docker.network` label tells Traefik which network to
use when multiple are attached.

**Priority confusion.** When two routers could match the same request
(overlapping `Host` rules), Traefik sorts by rule length by default. Use an
explicit `priority` label when you need to override that.

**Internal-only services.** Set `traefik.docker.network` carefully and use
`traefik.enable=false` on services you don&apos;t want exposed at all — it&apos;s
safer than relying on network isolation.

## Verifying Your Config

`docker logs traefik` will tell you which routers it discovered. The Traefik
dashboard (if you&apos;ve enabled it) shows the full configuration tree. When
labels aren&apos;t working, the answer is almost always in one of those two
places.

That&apos;s the workflow: label the container, attach the network, watch the
logs. Everything else is middlewares.</content:encoded><category>traefik</category><category>docker</category><category>homelab</category></item><item><title>Docker Compose Patterns I Actually Use</title><link>https://upmann.dev/blog/docker-compose-patterns/</link><guid isPermaLink="true">https://upmann.dev/blog/docker-compose-patterns/</guid><description>Six compose patterns that survive production: healthchecks, depends_on with conditions, profiles, and restart semantics.</description><pubDate>Mon, 12 Feb 2024 00:00:00 GMT</pubDate><content:encoded>Docker Compose is the lingua franca of self-hosting. After running a homelab
for three years, six patterns have survived every refactor.

## 1. Healthchecks Are Not Optional

Without a `healthcheck`, Compose has no way to know whether a container is
actually serving traffic — only that the process hasn&apos;t exited. A database
that&apos;s stuck in crash-loop recovery will look &quot;healthy&quot; to `depends_on`.

```yaml
services:
  db:
    image: postgres:16
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U app&quot;]
      interval: 10s
      timeout: 3s
      retries: 5
```

## 2. `depends_on` With Conditions

The bare `depends_on: [db]` only waits for the container to start. Pair it
with a service condition to wait until the dependency is genuinely ready:

```yaml
api:
  depends_on:
    db:
      condition: service_healthy
```

This single change eliminates an entire class of &quot;first boot fails, retry
works&quot; bugs.

## 3. Compose Profiles

A single `compose.yaml` can serve multiple environments with profiles:

```yaml
services:
  app: { ... }
  debug-tools:
    profiles: [debug]
    image: nicolaka/netshoot
```

`docker compose up` starts the app; `docker compose --profile debug up` adds
the troubleshooting sidecar.

## 4. Restart Policies

`restart: unless-stopped` is almost always what you want. `always` will
re-launch containers after a reboot even if you deliberately stopped them,
which is rarely the intent on a development machine.

## 5. Named Volumes Over Bind Mounts for State

Bind mounts are convenient for source code but treacherous for databases —
filesystem permission and ownership semantics will eventually bite. Use named
volumes for anything you can&apos;t recreate from a config file.

## 6. One Compose File, Many Overlays

The `-f` flag composes multiple files. Keep a base `compose.yaml` for the
service definitions and a `compose.production.yaml` that overrides labels,
resource limits, and networks. This keeps environment-specific noise out of
the main file.

## Wrapping Up

These patterns aren&apos;t exotic — they&apos;re the boring defaults I keep coming back
to. The unifying principle: make the system&apos;s actual state match what
Compose thinks it is.</content:encoded><category>docker</category><category>homelab</category></item><item><title>Hello World</title><link>https://upmann.dev/blog/hello-world/</link><guid isPermaLink="true">https://upmann.dev/blog/hello-world/</guid><description>First post — what this site is about and what&apos;s coming.</description><pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate><content:encoded>Welcome to **upmann.dev** — my corner of the internet for writing about
homelab infrastructure, automation, and the tools I use day-to-day.

## What to Expect

This blog will cover:

- **Homelab builds** — servers, networking, storage, and power
- **Self-hosting** — Docker, Traefik, Caddy, and the services I run
- **Automation** — Ansible, Terraform, and infrastructure-as-code
- **Development** — TypeScript, Astro, and the tools that make building fun

## The Stack

This site itself is an exercise in the stack I enjoy working with:

- **Astro** for the framework — fast, content-first, zero JS by default
- **Tailwind CSS** for styling — utility-first, no context-switching
- **MDX** for content — Markdown with component superpowers
- **Docker + Caddy** for deployment — self-hosted behind Traefik

## What&apos;s Next

I&apos;ll be documenting my homelab journey, sharing configurations, and
writing about the tools and workflows I find useful. Stay tuned for
more technical deep-dives.</content:encoded><category>meta</category></item></channel></rss>