<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>@dlhck — David Höck</title>
    <link>https://website-d20cpcoms-dlhck-personal.vercel.app</link>
    <description>CEO &amp; co-founder, Vendure. Writing about building software and companies.</description>
    <language>en</language>
    <lastBuildDate>Fri, 09 Oct 2026 08:12:18 GMT</lastBuildDate>
    <atom:link href="https://website-d20cpcoms-dlhck-personal.vercel.app/rss.xml" rel="self" type="application/rss+xml"/>
    <managingEditor>david@dlhck.com (David Höck)</managingEditor>
    <webMaster>david@dlhck.com (David Höck)</webMaster>
    <item>
      <title>Introducing Ferry</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/introducing-ferry</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/introducing-ferry</guid>
      <pubDate>Sun, 27 Sep 2026 00:00:00 GMT</pubDate>
      <description>Sync your coding-agent skills, instructions, plugins, and MCP server declarations to a remote Linux box. An introduction to Ferry, with setup instructions and terminal examples.</description>
      <content:encoded><![CDATA[<p><a href="https://github.com/dlhck/ferry">Ferry</a> syncs your coding-agent setup from your laptop to a remote Linux machine. It carries skills, instructions, Claude plugins and settings, and remote MCP server declarations through a private git repository. You log in to each tool on the remote machine.</p><p>I built it for the gap between configuring an agent and using that agent somewhere else. A skill you added on your laptop should be available on the box. A change to your global instructions should reach both. Keeping those files in sync by hand becomes another job every time you improve your setup.</p><p>Copying all of <code>~/.claude</code> would also copy credentials and session data. Ferry selects the configuration to share and leaves authentication on each machine. <Highlight>Your setup travels. Your logins stay on the machine that created them.</Highlight></p><p>The sections below cover setup and daily use, followed by the details of what Ferry syncs.</p><p>- <a href="#requirements">Install and first sync</a>
- <a href="#logging-in-on-the-box">Log in to your tools</a>, with terminal examples
- <a href="#moving-a-project">Move a project</a> between your laptop and the box
- <a href="#what-ferry-carries">What Ferry carries</a> and <a href="#security-model">its security model</a>
- <a href="#remote-mcp-servers">Remote MCP servers</a>
- <a href="#automatic-sync">Automatic sync</a> and <a href="#keeping-the-agent-tools-up-to-date">updates</a>
- <a href="#commands">Command reference</a></p><p><h3>How it works</h3></p><p>Ferry has three parts:</p><p>- <strong>The operator machine.</strong> This is your laptop, macOS or Linux. Ferry runs here, and here is where you edit your skills and settings.
- <strong>A private snapshot repository.</strong> A git repository that you create, for example <code>git@github.com:you/ferry-snapshot.git</code>. It holds the skills, the global instruction file, the Claude subagents and commands, an allowlist of Claude settings keys, and the remote MCP server declarations.
- <strong>The box.</strong> A Linux machine that you reach over Tailscale or SSH. The box does not run Ferry. Ferry runs commands on it over SSH.</p><p>Both machines clone the snapshot repository into <code>~/.ferry/store</code>. Ferry links managed paths into that clone. Individual skills link to the store; Claude subagent and command directories link as whole directories. On your laptop you keep editing files where you always did. <code>ferry sync</code> commits and pushes the store, then connects to the box, pulls the new commit, and links everything into place.</p><p>Sync runs from your laptop to the box. Make shared configuration changes on your laptop. If you remove a carried settings key on your laptop, sync removes it on the box too.</p><p>Authentication happens separately on each machine. When the box needs a login, Ferry starts the vendor login on the box and prints the URL. You finish the login in a browser on your laptop, and the token stays on the box.</p><p><h3>Requirements</h3></p><p>- An operator machine with macOS or Linux. Windows is not supported.
- A Linux box that you can reach with SSH. <code>ferry install</code> and <code>ferry update</code> use <code>apt</code> for <code>gh</code>, so Debian or Ubuntu is the tested target.
- Tailscale on both machines, or one OpenSSH destination such as <code>user@box.example</code>.
- An empty private git repository for the snapshot. Ferry does not create it.
- For an SSH snapshot URL, an SSH agent on the operator machine with a loaded key that can read and push the snapshot repository.
- <a href="https://bun.sh">Bun</a> 1.4 or later, if you install from source.</p><p><h3>Install</h3></p><p>The quickest way is the install script:</p><p><code>`</code>sh
curl -fsSL https://raw.githubusercontent.com/dlhck/ferry/main/install.sh | sh
<code>`</code></p><p>The script downloads the executable for your platform from the latest <a href="https://github.com/dlhck/ferry/releases">GitHub release</a>, checks it against the release's <code>SHA256SUMS</code> file, and installs it to <code>~/.local/bin/ferry</code>. It supports macOS and Linux on arm64 and x64, and it needs <code>curl</code> or <code>wget</code> plus <code>sha256sum</code> or <code>shasum</code>. If <code>~/.local/bin</code> is not on your <code>PATH</code>, the script prints the line to add to your shell profile.</p><p>Run the remaining commands on your laptop. Ferry runs commands on the box over SSH; you do not install Ferry there.</p><p>To update Ferry, run the same command again. Three variables change what the script does:</p><p>- <code>FERRY_VERSION</code> installs a specific release, for example <code>curl -fsSL https://raw.githubusercontent.com/dlhck/ferry/main/install.sh | FERRY_VERSION=v0.2.0 sh</code>.
- <code>FERRY_INSTALL_DIR</code> installs to a different directory.
- <code>FERRY_SKIP_CHECKSUM=1</code> skips the checksum check. You need it only for v0.1.1 or earlier, which have no <code>SHA256SUMS</code> file.</p><p>If you run <a href="#automatic-sync"><code>ferry watch</code></a> as a service, it keeps the old executable until it restarts. Restart it after an update with <code>launchctl kickstart -k gui/$(id -u)/dev.ferry.watch</code> on macOS or <code>systemctl --user restart ferry-watch.service</code> on Linux.</p><p>Ferry is also on npm:</p><p><code>`</code>sh
npm i -g @dlhck/ferry
<code>`</code></p><p>The package runs the same prebuilt executable and does not need Bun. npm installs only the executable for your platform, from the optional dependency <code>@dlhck/ferry-<os>-<arch></code>, so do not install with <code>--omit=optional</code>. Update with <code>npm i -g @dlhck/ferry@latest</code>. <code>ferry update</code> updates the agent tools, not Ferry itself.</p><p>To install from source with Bun 1.4 or later:</p><p><code>`</code>sh
git clone https://github.com/dlhck/ferry.git
cd ferry
bun install
bun link
<code>`</code></p><p><code>bun link</code> puts <code>ferry</code> on your <code>PATH</code> and runs <code>src/cli.ts</code> with Bun.</p><p><h3>Quick start</h3></p><p><code>`</code>sh
ferry init          # record the box and the snapshot URL, seed the snapshot, link this machine
ferry install       # install gh, Claude Code, Codex, Pi, and Cursor Agent on the box
ferry sync          # publish the snapshot and apply it on the box
ferry auth gh       # start a login on the box, finish it in a browser here
ferry auth claude
ferry status        # check the link, the snapshot, the managed paths, and the box logins
<code>`</code></p><p><code>ferry install</code> shows each install command and asks before it runs it on the box. Repeat <code>ferry auth</code> for each tool you use. Add <code>--dry-run</code> to <code>init</code>, <code>sync</code>, or <code>update</code> to see the changes before Ferry makes them.</p><p>After setup, edit skills and settings on your laptop and run <code>ferry sync</code>, or use <a href="#automatic-sync"><code>ferry watch</code></a>.</p><p><h3>Connecting to the box</h3></p><p><code>ferry init</code> asks whether to use a Tailscale host or an SSH-only destination. You can also pass the answer as flags.</p><p>A Tailscale peer:</p><p><code>`</code>sh
ferry init \
  --host build-box \
  --ssh-user ferry \
  --snapshot-url git@github.com:you/ferry-snapshot.git
<code>`</code></p><p>An explicit OpenSSH destination:</p><p><code>`</code>sh
ferry init \
  --ssh-destination user@box.example \
  --snapshot-url git@github.com:you/ferry-snapshot.git
<code>`</code></p><p>Ferry stores the transport in <code>~/.ferry/config.toml</code>. It never falls back from Tailscale to direct SSH, and SSH-only mode never runs Tailscale.</p><p>With an SSH snapshot URL, Ferry checks that your SSH agent has a loaded identity, forwards the agent to the box, and tests read access to the snapshot repository. If the git host is not in the box's <code>known_hosts</code>, Ferry prints the host-key fingerprints and asks before it adds them. Your private key stays on your machine.</p><p><h3>Logging in on the box</h3></p><p>Run <code>ferry auth <tool></code> for <code>gh</code>, <code>claude</code>, <code>codex</code>, or <code>cursor</code>.</p><p>Ferry first checks whether the tool is already logged in on the box. If it isn't, Ferry starts the vendor login on the box, detached from the SSH session, and prints the URL and code as soon as the login shows them. You open the URL on your machine and finish there. Ferry checks the box every 5 seconds and reports when the login is complete. The login on the box stops after 15 minutes.</p><p>- <strong><code>gh</code></strong> prints a one-time code for <code>https://github.com/login/device</code>.
- <strong><code>claude</code></strong> prints the login URL. After the login, the browser shows a code. Paste it at the Ferry prompt, and Ferry hands it to the login on the box.
- <strong><code>codex</code></strong> prints a one-time code for <code>https://auth.openai.com/codex/device</code>. If the device login gives no code, Ferry uses the Codex callback login. It prints the URL and forwards local port 1455 to the box for 120 seconds. Press Ctrl-C after the browser reports success.
- <strong><code>cursor</code></strong> prints the login URL.</p><p>Pi has no remote login that Ferry can start. SSH to the box, run <code>pi</code>, and use <code>/login</code>.</p><p><code>ferry auth</code> without a tool shows which logins Ferry can start:</p><p><code>`</code>shellsession
$ ferry auth
gh: startable
claude: startable
codex: startable
cursor: startable
pi: manual SSH flow
<code>`</code></p><p><h3>Examples</h3></p><p>These illustrative transcripts follow Ferry's output messages; they are not recordings of live sessions. Codes and the <code>build-box</code> hostname are examples, and authorization URLs are shortened. Use the URL and code printed by your own command.</p><p>A first <code>gh</code> login. After the login, Ferry sets up GitHub over SSH and prints one line per step:</p><p><code>`</code>shellsession
$ ferry auth gh
│
◇  Starting the gh login on the box
URL: https://github.com/login/device
Code: 7F3K-92QD
Open the URL in a browser on this machine and enter the code.
│
◇  Waiting for you to finish the login in the browser (up to 15 min)
gh: logged in
SSH key: added to GitHub as build-box (ferry)
known_hosts: added github.com
git protocol: ssh
ssh -T git@github.com: authenticated
<code>`</code></p><p>The same command when gh is already logged in:</p><p><code>`</code>shellsession
$ ferry auth gh
│
◇  Starting the gh login on the box
│
◇  Checking the gh setup on the box
gh: already authenticated
SSH key: already on GitHub
git protocol: ssh
ssh -T git@github.com: authenticated
<code>`</code></p><p>A Claude login. The browser shows a code at the end, and you paste it at the prompt. Ferry masks it:</p><p><code>`</code>shellsession
$ ferry auth claude
│
◇  Starting the claude login on the box
URL: https://claude.ai/oauth/authorize?code=true&client_id=...
Open the URL in a browser on this machine. After the login, paste the code that the browser shows.
│
◇  Code from the browser
│  ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪
│
◇  Waiting for you to finish the login in the browser (up to 15 min)
claude: logged in
<code>`</code></p><p>A Codex device login:</p><p><code>`</code>shellsession
$ ferry auth codex
│
◇  Starting the codex login on the box
URL: https://auth.openai.com/codex/device
Code: Q8RZ4-TK27M
Open the URL in a browser on this machine and enter the code.
│
◇  Waiting for you to finish the login in the browser (up to 15 min)
codex: logged in
<code>`</code></p><p>In a terminal, each <code>◇</code> line is a spinner until its step is done. When Ferry doesn't run in a terminal, for example in a script, it writes each step as one plain line, such as <code>Starting the gh login on the box...</code>.</p><p><h3>GitHub over SSH</h3></p><p>Ferry forwards your SSH agent only for its own snapshot update, so agents on the box need their own key to push. <code>ferry auth gh</code> sets that up:</p><p>1. Before the login, Ferry creates <code>~/.ssh/id_ed25519</code> on the box if it does not exist. The key has no passphrase and the comment <code><box user>@<box host> ferry</code>. Ferry never replaces an existing key.
2. The gh login asks for the <code>admin:public_key</code> scope, which gh needs to add the key.
3. After the login, Ferry adds <code>~/.ssh/id_ed25519.pub</code> to your GitHub account with the title <code><box host> (ferry)</code>, unless GitHub already has it.
4. If the box <code>~/.ssh/known_hosts</code> has no <code>github.com</code> entry, Ferry reads the ed25519 host key with <code>ssh-keyscan</code>. It adds the key only if the fingerprint matches the one the GitHub meta API publishes.
5. Ferry runs <code>gh config set -h github.com git_protocol ssh</code>.
6. Ferry runs <code>ssh -T git@github.com</code> and reports whether GitHub accepts the key.</p><p>If you don't finish the login, Ferry skips steps 3 to 6. If gh is already logged in, Ferry runs only steps 1 and 3 to 6. A gh login without the <code>admin:public_key</code> scope cannot add the key. Run <code>gh auth refresh -h github.com -s admin:public_key</code> on the box, then run <code>ferry auth gh</code> again.</p><p>To revoke the box key, delete the key titled <code><box host> (ferry)</code> under GitHub Settings > SSH and GPG keys, or use <code>gh ssh-key list</code> and <code>gh ssh-key delete <id></code>. To remove it from the box, delete <code>~/.ssh/id_ed25519</code> and <code>~/.ssh/id_ed25519.pub</code> there.</p><p><h3>Moving a project</h3></p><p>Your setup is on both machines, but a project usually lives on one of them. <code>ferry move</code> moves a project to the other machine, so an agent on the box can pick up where you stopped, or you can take the box's work back to your laptop.</p><p><code>`</code>sh
ferry move ~/Developer/app                # this machine -> box
ferry move --from-box ~/Developer/app     # box -> this machine
ferry move ~/Developer/app --dry-run      # show what Ferry carries, refuses, and skips
ferry move ~/Developer/app --remove       # after verification, move the source copy to a trash directory
ferry move ~/Developer/app --include-env  # also carry .env files that pass the deny rules
<code>`</code></p><p>The project must be inside your home directory, and it keeps the same path relative to home. <code>~/Developer/app</code> on your laptop becomes <code>~/Developer/app</code> on the box. Ferry doesn't copy the git history. The destination clones the project from <code>origin</code>, and Ferry carries only the files that git doesn't have. The steps:</p><p>1. <strong>Preflight.</strong> Ferry reads the branches and tags of <code>origin</code> with <code>git ls-remote</code>. It refuses the move if a commit on a local branch is not on <code>origin</code>, unless <code>git cherry</code> shows an equivalent patch on the default branch of <code>origin</code>. Ferry does not fetch, so run <code>git fetch</code> first if the patch was merged recently. Ferry also refuses uncommitted changes to tracked files and a destination path that already exists. It reports stashes, because the clone does not get them.
2. <strong>Clone.</strong> The destination clones the project from <code>origin</code>, at the current branch if <code>origin</code> has it. The box uses its own SSH key, so run <a href="#github-over-ssh"><code>ferry auth gh</code></a> first.
3. <strong>Carry.</strong> Ferry carries the untracked and ignored files. It skips <code>node_modules</code>, <code>.next</code>, <code>.nuxt</code>, <code>.svelte-kit</code>, <code>.turbo</code>, <code>.cache</code>, <code>.parcel-cache</code>, <code>dist</code>, <code>build</code>, <code>coverage</code>, <code>target</code>, <code>__pycache__</code>, <code>.venv</code>, <code>venv</code>, <code>.gradle</code>, <code>.terraform</code>, <code>.idea</code>, <code>.vscode</code>, <code>.DS_Store</code>, and <code>._*</code> at any depth. Each carried file must pass the same <a href="#deny-rules">deny rules</a> as a sync. Ferry refuses symbolic links and nested git repositories.
4. <strong>Environment files.</strong> Ferry refuses <code>.env</code> and <code>.env.*</code> files. With <code>--include-env</code>, it carries an environment file only if it has no token and no <code>PASSWORD</code>, <code>SECRET</code>, <code>API_KEY</code>, or similar key with a value.
5. <strong>Verify.</strong> Ferry compares the SHA-256 checksum of each file on the destination with the checksum of the bytes it checked.
6. <strong>Paseo.</strong> If the destination has the <code>paseo</code> CLI, Ferry runs <code>paseo project create <path></code> there.
7. <strong>Remove.</strong> With <code>--remove</code>, Ferry moves the source copy away after verification: to <code>~/.Trash/<name>-<timestamp></code> on macOS, and to <code>~/.ferry/trash/<name>-<timestamp></code> on the box and on Linux. Ferry never deletes the source copy. If Ferry refuses a local-only file, it refuses <code>--remove</code> before it changes anything.</p><p>A folder without git has no clone. Ferry copies all its files with the same skip list and deny rules.</p><p><code>--dry-run</code> prints the plan: the clone, and each file that Ferry carries, refuses, or skips. It writes nothing on either machine. With <code>--from-box</code>, Ferry reads the box files into a temporary directory on your laptop for the deny checks and removes that directory after the run.</p><p>:::warning
If a step fails after the clone, the destination copy is incomplete. Ferry does not remove it. Move it away before you try again.
:::</p><p><h3>Supported harnesses</h3></p><p>Ferry supports the following tools and their global configuration directories. Ferry calls each configuration a harness:</p><p>| Harness | Skill root | Instruction file | MCP servers |
| --- | --- | --- | --- |
| Shared agents | <code>~/.agents/skills</code> | <code>~/AGENTS.md</code> | None |
| Claude Code | <code>~/.claude/skills</code> | <code>~/.claude/CLAUDE.md</code> | <code>~/.claude.json</code> |
| Codex | <code>~/.codex/skills</code> | <code>~/.codex/AGENTS.md</code> | <code>~/.codex/config.toml</code> |
| Pi | <code>~/.pi/agent/skills</code> | <code>~/.pi/agent/AGENTS.md</code> | None |
| Cursor Agent | <code>~/.cursor/skills</code> | None | <code>~/.cursor/mcp.json</code> |</p><p><code>~/AGENTS.md</code> is the shared global instruction file. Ferry links it as the instruction file of each harness, so every agent reads the same instructions. You can add your own harness. See <a href="#custom-harnesses">Custom harnesses</a>.</p><p><h3>Security model</h3></p><p>Ferry carries skills, <code>~/AGENTS.md</code>, the Claude subagents and commands, an allowlist of Claude settings keys, and the name and HTTPS URL of each remote MCP server. <a href="#what-ferry-carries">What Ferry carries</a> has the details.</p><p>Ferry excludes:</p><p>- credential files, OAuth sessions, or Keychain items
- tokens, API keys, MCP tokens, or request headers
- settings keys that can hold secrets, such as <code>env</code> and <code>apiKeyHelper</code>
- local MCP servers, which have a <code>command</code>, <code>args</code>, or <code>env</code>
- session history, caches, databases, or whole settings files</p><p>Ferry checks carried files against deny rules for credential filenames, known token patterns, and secret fields. A match stops the sync. The error names the file without printing the secret value.</p><p>Ferry opens no public listening port. The Codex callback login forwards local port 1455 to the box for 120 seconds. Ferry never turns off SSH host-key checks. It forwards your SSH agent to the box only for the snapshot update.</p><p><code>ferry auth gh</code> creates an SSH key without a passphrase on the box and adds it to your GitHub account, so agents on the box can push. See <a href="#github-over-ssh">GitHub over SSH</a>.</p><p>:::warning
Ferry runs commands on the box as your SSH user. Use a box and an SSH user that you trust with the agents that run there.
:::</p><p><h3>What Ferry carries</h3></p><p>- <strong>Skills</strong> from every global harness skill root, such as <code>~/.agents/skills</code> and <code>~/.claude/skills</code>. Ferry skips the <code>.system</code> directory, because Codex manages it and installs its own copy on each machine.
- <strong><code>~/AGENTS.md</code></strong>, linked as the instruction file of each harness.
- <strong>Claude subagents and commands</strong> in <code>~/.claude/agents</code> and <code>~/.claude/commands</code>. Ferry links each directory whole into the store. On the box, a live directory at one of these paths stops the sync. <code>ferry sync --force</code> moves it to <code>~/.ferry/backups</code> and then links it.
- <strong>Four keys from <code>~/.claude/settings.json</code></strong>: <code>enabledPlugins</code>, <code>extraKnownMarketplaces</code>, <code>permissions</code>, and <code>hooks</code>. The snapshot holds them in <code>settings/claude.json</code>. No other key and no plugin cache.
- <strong>Remote MCP servers</strong> from Claude, Codex, and Cursor Agent. Only the name, the transport, and the HTTPS URL. See <a href="#remote-mcp-servers">Remote MCP servers</a>.</p><p>It does not carry project-local skills.</p><p><h3>Deny rules</h3></p><p>The deny rules apply to every carried directory. Any of these stops the sync:</p><p>- A file such as <code>.env</code> or <code>credentials.json</code>, or content that matches a known token pattern.
- A JSON, YAML, or TOML file with a <code>password</code>, <code>passwd</code>, <code>secret</code>, <code>client_secret</code>, <code>private_key</code>, or <code>api_key</code> key that has a string value, at any depth. Ferry compares keys without regard to case, <code>-</code>, or <code>_</code>, so <code>clientSecret</code> matches too. If the file does not parse, Ferry checks each <code>key: value</code> or <code>key = value</code> line.
- An ELF, Mach-O, or PE executable. A script with a shebang and a Java class file are fine.
- A carried settings key that holds a token or request headers.</p><p>Empty values and recognized placeholders pass. For token examples in skill documentation, use a body made only of <code>x</code>, only of <code>X</code>, or only of <code>0</code>. Ferry also accepts <code>x</code> and <code>X</code> with the token format's separators, such as <code>xoxb-xxxx-xxxx-xxxx</code>. Arbitrary fake values can still trigger the deny rules.</p><p><h3>Hooks</h3></p><p>A hook that points at a file in your home directory breaks on the box if that file isn't there. Ferry checks each hook <code>command</code> before it carries it.</p><p>Ferry splits the command into words at spaces, quotes, <code>;</code>, <code>|</code>, <code>&</code>, <code>(</code>, <code>)</code>, <code><</code>, <code>></code>, and <code>=</code>. A word that starts with <code>~/</code>, <code>$HOME/</code>, or <code>${HOME}/</code> must point into a place Ferry fills on the box: a skill root, <code>~/.claude/agents</code>, <code>~/.claude/commands</code>, or a managed instruction file such as <code>~/AGENTS.md</code>. An absolute path under your home, such as <code>/Users/you/bin/check.sh</code>, never passes, because the box home has a different path. Programs on <code>PATH</code>, such as <code>jq</code>, pass this check. So do <code>$CLAUDE_PROJECT_DIR</code> paths and absolute paths outside your home. You must make those programs and paths available on the box yourself.</p><p>Ferry leaves out each hook that fails the check and carries the rest. Ferry also removes matcher groups and events left empty by these exclusions. The sync continues, and <code>ferry sync</code> prints one <code>Skipped hook:</code> line per hook with its location, for example <code>hooks.PreToolUse[0].hooks[1].command</code>.</p><p>Scripts in <code>~/.claude/hooks</code> are not carried. Move them into a carried directory, such as a skill, or onto <code>PATH</code> on both machines.</p><p><h3>Claude plugins and settings</h3></p><p>Claude does not install a plugin from settings alone. So on the box, <code>ferry sync</code> runs <code>claude plugin marketplace add</code> for each carried marketplace and <code>claude plugin install</code> for each enabled plugin. Then it writes the carried keys into the box <code>~/.claude/settings.json</code>.</p><p>The box keeps all other keys, such as <code>env</code> and <code>model</code>. A carried key that your machine does not have is removed from the box. If you have no <code>hooks</code> key, the box ends up with no <code>hooks</code> key. A marketplace or plugin that the box cannot install gives a warning and does not stop the sync. A marketplace with a <code>directory</code> or <code>file</code> source lives on your machine only, so the box cannot add it.</p><p><code>ferry sync --dry-run</code> lists the carried settings keys that will change, compared with the last publish in <code>~/.ferry/store/settings</code>. It does not connect to the box, so it can't see changes someone made there.</p><p>:::info
If a plugin in <code>enabledPlugins</code> comes from a marketplace that <code>extraKnownMarketplaces</code> does not list, run <code>claude plugin marketplace add</code> for it once on your machine. For example, <code>claude plugin marketplace add anthropics/claude-plugins-official</code>.
:::</p><p><h3>Adding skills</h3></p><p>Use <code>ferry skills add</code> to install skills. It runs <code>npx skills add</code> and passes your arguments through:</p><p><code>`</code>sh
ferry skills add vercel-labs/agent-skills --skill frontend-design -a claude-code -y
<code>`</code></p><p>Ferry adds <code>-g</code> and <code>--copy</code> if you don't pass them, so the command above runs <code>npx skills add vercel-labs/agent-skills --skill frontend-design -a claude-code -y -g --copy</code>. A global copy install puts each skill in a real directory in a global skill root, and the next sync moves it into the store. A plain <code>npx skills add</code> installs into the current project, which Ferry does not see.</p><p>Add <code>--project</code> to keep a project install. Ferry then does not add <code>-g</code>. Put arguments after <code>--</code> to keep Ferry from reading them. If <code>npx skills add</code> fails, Ferry exits with the same code.</p><p>Installing a skill does not publish it. Run <code>ferry sync</code>, or keep <code>ferry watch</code> running.</p><p><h3>The Ferry agent skill</h3></p><p>Ferry has its own agent skill, in <a href="https://github.com/dlhck/ferry/blob/main/skills/ferry/SKILL.md"><code>skills/ferry</code></a>. It tells agents on both machines how to work with Ferry. The most important rule is for agents on the box: do not edit a Ferry-managed file there, because the next sync resets it. The skill also tells agents to run <code>--dry-run</code> before <code>ferry sync</code> and <code>ferry move</code>, to leave <code>ferry auth</code> and confirmation prompts to you, and never to work around a deny rule.</p><p>Install it on your laptop and sync it to the box like any other skill:</p><p><code>`</code>sh
ferry skills add dlhck/ferry --skill ferry
ferry sync
<code>`</code></p><p><h3>When an installer replaces a link</h3></p><p>Some installers replace the store link of a skill with a real directory that holds a newer version, for example in <code>~/.agents/skills</code>. Sync then updates the store copy from that directory, publishes it, and links the directory back to the store. It does this only when:</p><p>- exactly one skill root has a real directory for the skill,
- every other skill root that has the skill links to <code>~/.ferry/store/skills/<name></code>, directly or through a chain of links, and
- the store copy has no uncommitted changes in <code>~/.ferry/store</code>.</p><p>In every other case, sync stops with a clash and leaves the decision to you. <code>ferry sync</code> prints <code>Updated store skill <name> from <path></code> for each update, and <code>ferry sync --dry-run</code> lists them under <code>Store updates from a harness root:</code>.</p><p><h3>Remote MCP servers</h3></p><p>Ferry supports remote MCP server declarations with a name and an HTTPS URL. Servers such as Linear use OAuth for authentication. Ferry declares these servers on the box. It never copies an MCP token, so you log in to each server on the box.</p><p>Ferry reads only the MCP key of each file: <code>mcpServers</code> in <code>~/.claude.json</code>, <code>[mcp_servers]</code> in <code>~/.codex/config.toml</code>, and <code>mcpServers</code> in <code>~/.cursor/mcp.json</code>. It does not include the account data from <code>~/.claude.json</code> in the snapshot.</p><p>Ferry skips local servers, which have a <code>command</code>, and servers with a plain <code>http://</code> URL. Sync prints one <code>Skipped MCP server:</code> line for each. Ferry stops the sync if a remote server has any of these:</p><p>- request headers (<code>headers</code>, <code>headersHelper</code>, <code>http_headers</code>, <code>env_http_headers</code>, <code>http_headers_helper</code>)
- a <code>bearer_token_env_var</code>, an <code>env</code> or <code>env_vars</code> value, or <code>args</code>
- a URL with a user name, a password, a token, or a query parameter such as <code>api_key</code> or <code>token</code>
- a name with characters other than letters, digits, <code>.</code>, <code>_</code>, and <code>-</code>, because the name goes into commands on the box</p><p>On the box, sync declares each server:</p><p>- <strong>Claude:</strong> <code>claude mcp add --transport http --scope user <name> <url></code>
- <strong>Codex:</strong> <code>codex mcp add <name> --url <url></code>. This command also starts a login and waits for it, so Ferry stops it after 20 seconds. Codex writes the declaration before the login starts.
- <strong>Cursor Agent:</strong> Ferry writes the server into <code>mcpServers</code> in the box <code>~/.cursor/mcp.json</code>, because Cursor Agent has no add command.</p><p>Pi has no MCP support of its own, so Ferry declares no Pi servers.</p><p>If the box already has a server with the same URL, Ferry leaves it alone and its login stays. If the URL changed, Ferry replaces the declaration and you log in again. Ferry never removes a server from the box. If you remove a server on your machine, remove it on the box yourself. A missing CLI or a rejected server gives a <code>Box MCP:</code> warning and does not stop the sync.</p><p><code>ferry status</code> lists each box MCP server that needs a login, and the command to run:</p><p><code>`</code>shellsession
$ ferry status
...
MCP logins:
  claude/linear: LOGIN REQUIRED, run ferry auth claude --mcp linear
  codex/linear: LOGIN REQUIRED, run ferry auth codex --mcp linear
...
<code>`</code></p><p>Ferry starts the tool's MCP login on the box under <code>script</code>, because Claude and Codex need a terminal, and prints the authorize URL. Open it on your machine. The browser then redirects to a <code>localhost</code> callback port, which Ferry forwards to the box for 300 seconds, so the tool on the box gets the callback and keeps the token. Press Ctrl-C when the browser reports success. The callback port must be free on your machine. Claude uses port 3118.</p><p><code>`</code>shellsession
$ ferry auth claude --mcp linear
│
◇  Starting the claude/linear MCP login on the box
URL: https://mcp.linear.app/authorize?response_type=code&client_id=...&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&...
Open the URL in a browser on this machine.
Ferry forwards local port 3118 to the box for 300 s. Press Ctrl-C after the browser reports success.
<code>`</code></p><p>Run <code>ferry status</code> again to verify the login. After a successful login, the server should no longer appear under <code>MCP logins:</code>.</p><p><h3>Automatic sync</h3></p><p><code>ferry watch</code> runs in the foreground and watches every global skill root, <code>~/AGENTS.md</code>, the Claude subagents and commands, and the carried Claude settings keys. When a change stays stable for one second, it runs a normal sync without <code>--force</code>. Network, SSH, and git failures retry with a backoff capped at 60 seconds. If the deny rules refuse a file, the watch names the path and waits for your next edit.</p><p>To run it as a user service:</p><p><code>`</code>sh
ferry watch install
<code>`</code></p><p>The installer uses the absolute path of the running Ferry executable and records the current <code>PATH</code> and, if set, <code>SSH_AUTH_SOCK</code>. Run it again after you move Ferry or either value changes.</p><p>On macOS, Ferry writes <code>~/Library/LaunchAgents/dev.ferry.watch.plist</code>, unloads an older Ferry job, and bootstraps the new one. The log goes to <code>~/Library/Logs/ferry-watch.log</code>. launchd does not inherit your shell's <code>PATH</code> or <code>SSH_AUTH_SOCK</code>, so before you install, make sure <code>PATH</code> contains <code>git</code>, <code>ssh</code>, and, for a Tailscale host, <code>tailscale</code>. If the snapshot remote needs an SSH agent, point <code>SSH_AUTH_SOCK</code> at its stable socket.</p><p>On Linux, Ferry writes <code>~/.config/systemd/user/ferry-watch.service</code>, reloads the user manager, and enables and starts the service. Read the logs with:</p><p><code>`</code>sh
journalctl --user -u ferry-watch.service -f
<code>`</code></p><p>:::warning
Do not put tokens or private keys in the plist or the unit file.
:::</p><p><h3>Keeping the agent tools up to date</h3></p><p><code>ferry update</code> updates the agent tools, not Ferry itself. To update a standalone Ferry installation, replace the executable with a newer release and restart the watch service if you use it.</p><p>The agent update commands are:</p><p>| Tool | Update command | On your machine |
| --- | --- | --- |
| <code>gh</code> | <code>sudo apt update && sudo apt install gh -y</code> | Skipped. Ferry does not guess your package manager. |
| Claude Code | <code>claude update</code> | Runs if installed. |
| Codex | <code>codex update</code> | Runs if installed. |
| Pi | <code>pi update</code> | Runs if installed. Updates Pi only, not its packages. |
| Cursor Agent | <code>cursor-agent update</code> | Runs if installed. |</p><p>Ferry finds each tool with <code>command -v</code> and never installs a tool on your machine. It prints the plan and asks first. <code>--yes</code> skips the prompt, and <code>--dry-run</code> prints the plan only. A failed update does not stop the others. At the end, Ferry names each failure and exits non-zero.</p><p>The watch can also run <code>ferry update --yes</code> once a day. Add this to <code>~/.ferry/config.toml</code> and restart the watch:</p><p><code>`</code>toml
[update]
watch = true
<code>`</code></p><p>The update runs next to the sync loop, so it never delays a sync. Ferry records the last start time in <code>~/.ferry/update-state.json</code>, so restarting the watch does not trigger an extra update.</p><p><h3>Passwordless sudo for the gh update</h3></p><p>The <code>gh</code> update needs <code>sudo</code> on the box, and the watch has no terminal to type a password. Membership in the <code>sudo</code> group is not enough, because the default rule on Ubuntu and Debian asks for a password. Add this rule on the box, with your SSH user in place of <code><ssh-user></code>:</p><p><code>`</code>
<h3>/etc/sudoers.d/ferry  (edit with: sudo visudo -f /etc/sudoers.d/ferry)</h3>
<ssh-user> ALL=(root) NOPASSWD: /usr/bin/true, /usr/bin/apt update, /usr/bin/apt install gh -y
<code>`</code></p><p>sudo matches the full path and all arguments, so the rule allows exactly these three commands. <code>/usr/bin/true</code> is there so <code>ferry status</code> can run <code>sudo -n /usr/bin/true</code> to test whether sudo asks for a password. Ferry never writes sudoers files itself.</p><p><code>ferry status</code> shows <code>Box sudo: PASSWORDLESS</code> or <code>Box sudo: PASSWORD REQUIRED</code>, and <code>--json</code> has it in <code>boxSudo</code>. With <code>[update] watch = true</code> and a password prompt, status warns that the watch cannot update <code>gh</code>.</p><p><h3>Custom harnesses</h3></p><p>If you use a harness Ferry doesn't know, declare it in <code>~/.ferry/config.toml</code>:</p><p><code>`</code>toml
[[harness]]
id = "opencode"
name = "OpenCode"
skill_root = ".config/opencode/skills"
instruction_file = ".config/opencode/AGENTS.md"
<code>`</code></p><p>Every command uses these entries, and <code>ferry init</code> keeps them when it rewrites the file. A custom harness can't add extra directories or settings keys to the snapshot. Ferry refuses unknown keys or sections in the config and names them.</p><p><h3>Commands</h3></p><p>| Command | What it does |
| --- | --- |
| <code>ferry init</code> | Records the box and the snapshot URL, seeds the snapshot, and links this machine. |
| <code>ferry install</code> | Installs <code>gh</code>, Claude Code, Codex, Pi, and Cursor Agent on the box. |
| <code>ferry sync</code> | Publishes the snapshot and applies it on the box. |
| <code>ferry status</code> | Reports the link, the snapshot, the managed paths, the box logins, and MCP servers that need a login. |
| <code>ferry auth <tool></code> | Starts a login on the box for <code>gh</code>, <code>claude</code>, <code>codex</code>, or <code>cursor</code>. |
| <code>ferry update</code> | Runs the update command of each agent tool on the box and on this machine. |
| <code>ferry skills add</code> | Runs <code>npx skills add</code> with the flags Ferry needs. |
| <code>ferry move <path></code> | Moves a project to the box, or with <code>--from-box</code> back to this machine. |
| <code>ferry watch</code> | Syncs automatically when a carried file changes. |
| <code>ferry watch install</code> | Installs <code>ferry watch</code> as a launchd or systemd user service. |
| <code>ferry uninstall</code> | Removes Ferry and restores the paths it replaced. |
| <code>ferry --version</code> | Prints the installed Ferry version. |</p><p>The useful flags:</p><p>- <code>--dry-run</code> on <code>init</code>, <code>sync</code>, <code>update</code>, and <code>move</code> prints the plan and changes nothing.
- <code>--from-box</code>, <code>--remove</code>, and <code>--include-env</code> on <code>move</code> set the direction, move the source copy to a trash directory, and carry <code>.env</code> files that pass the deny rules.
- <code>--yes</code> on <code>install</code>, <code>update</code>, and <code>uninstall</code> skips the confirmation prompt.
- <code>--force</code> on <code>sync</code> moves live directories on the box to <code>~/.ferry/backups</code> before it links them.
- <code>-m, --message</code> on <code>sync</code> sets the snapshot commit message.
- <code>--json</code> on <code>status</code> prints the report as JSON.
- <code>--mcp <server></code> on <code>auth</code> starts the login of a remote MCP server on the box.</p><p><code>init</code>, <code>sync</code>, <code>status</code>, <code>install</code>, <code>auth</code>, and <code>update</code> show the current step on stderr, for example <code>Publishing the snapshot</code> or <code>Installing Claude plugins (3/10)</code>. In a terminal you get a spinner. Otherwise Ferry writes one plain line per step. <code>ferry status --json</code> writes only the JSON report.</p><p><h3>Uninstalling</h3></p><p><code>ferry uninstall</code> removes the local store and config, removes the Ferry symlinks, and restores the paths that existed before your first <code>ferry init</code>. It refuses to write if you replaced a managed symlink with local content. It asks for confirmation first, and <code>--yes</code> skips the prompt.</p><p><h3>Try it</h3></p><p>Ferry is MIT licensed. Run the <a href="#install">install script</a>, create a private snapshot repository, and start with <code>ferry init --dry-run</code> to inspect the planned setup.</p><p>The <a href="https://github.com/dlhck/ferry">source and issue tracker</a> are on GitHub. See <a href="https://github.com/dlhck/ferry/blob/main/CONTRIBUTING.md">CONTRIBUTING.md</a> for contributions and <a href="https://github.com/dlhck/ferry/blob/main/SECURITY.md">SECURITY.md</a> to report a vulnerability.</p>]]></content:encoded>
    </item>
    <item>
      <title>AI × Judgment × Taste = Mega Software</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/ai-judgment-taste-mega-software</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/ai-judgment-taste-mega-software</guid>
      <pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate>
      <description>AI makes producing software easier than ever. The products that stand out will be shaped by judgment, taste, and a clear opinion about whom they serve.</description>
      <content:encoded><![CDATA[<p>A few days ago, I sent a message to <a href="https://x.com/michlbrmly">Michael</a>, my co-founder:</p><p>> AI + good judgment + taste = mega software</p><p>The more I thought about it, the more the plus signs felt wrong. Multiplication describes it better because AI amplifies the judgment and taste you bring to it.</p><p><Highlight>AI × judgment × taste = mega software.</Highlight></p><p>AI is an incredible accelerator. I am extremely bullish on it. It has completely changed how quickly we can turn an idea into working software. But faster production does not automatically result in a better product.</p><p>If you point AI at the wrong problem, it helps you get to the wrong result faster. If you accept the first reasonable output, you will end up with software that works but feels generic. Combined with good judgment and taste, however, it gives small teams a level of leverage that would have been impossible only a few years ago.</p><p><h3>Why AI Slop Is So Easy to Recognize</h3></p><p>There is a lot of software being produced right now. AI made plausible results incredibly cheap.</p><p>A first version can work, look acceptable, and come with professional sounding copy. But after using it for a few minutes, you notice that something is missing. The workflows feel generic. Every action seems equally important. Features exist because they were easy to add, not because somebody decided they should be there.</p><p>People have developed a feeling for this. They might not call it AI slop, and they might not be able to explain exactly what is wrong, but they recognize the absence of intent.</p><p>I don't think the bar for a good experience has increased that much. Clarity, coherence, usefulness, and care have always mattered. What changed is the quantity of software people encounter. We now see more software, compare more software, and recognize much faster when nobody held a high standard while building it.</p><p><h3>Judgment Starts With Whom You Serve</h3></p><p>For me, mega software is not software that tries to do everything for everyone.</p><p>It is software built for a clearly understood group of users. It is shaped around their everyday work, their problems, and the way they think. It improves their quality of life because somebody took the time to understand what matters to them.</p><p>Judgment decides whom the product is for, which problems are worth solving, which trade-offs to accept, and what to leave out. AI can help explore all of these questions. With enough context, it can propose very good solutions. But somebody still needs to decide which customer conversations matter, how to reconcile conflicting needs, and what the product should become.</p><p>AI can participate in that process, but a team first needs to develop and articulate its own judgment. Otherwise there is nothing meaningful for AI to work with.</p><p><h3>Opinions Inside a Customizable Platform</h3></p><p>At <a href="https://vendure.io">Vendure</a>, we intentionally built a highly customizable ecommerce platform. Our users bring their own opinions about how their final solution should work and look, and we do not prescribe one universal commerce experience.</p><p>Our opinions live one layer lower. We care deeply about how developers reach their desired outcome. The primitives, extension model, workflows, and boundaries are our responsibility.</p><p>I have been thinking about this a lot while working on the next iteration of our design system and adopting it in the Vendure Admin Dashboard. The dashboard has many extension surfaces where developers can add their own components and actions. In the past, we placed very few restrictions on these surfaces. You could put almost anything almost anywhere.</p><p>That sounds flexible, but flexibility without constraints often transfers the design burden to every extension author and, eventually, to every user.</p><p>Take an action bar that allows extensions to add buttons. Previously, an extension could add another primary button. And another. The result was a busy interface where users could no longer tell which action mattered most.</p><p>With the new design system, extensions can still add actions, but they cannot add primary ones. Our opinion is simple: <Highlight>every view should have one clear primary action.</Highlight></p><p>Developers still have the extension point, while the dashboard retains a clear hierarchy.</p><p>AI is excellent at accelerating the implementation of this migration. It can find extension surfaces, convert components, apply established patterns, and produce convincing interfaces. Deciding that unrestricted primary actions were a product-level design flaw required more than code context.</p><p>That conclusion came from context beyond the code: experience with the product, conversations with users, and a clear opinion about how the interface should behave.</p><p><h3>Taste and High Benchmarks</h3></p><p>Taste is harder to define because it is subjective. I see it almost as a character trait, something you develop over time through everything you have seen, built, rejected, and learned to care about.</p><p>Over time, you develop a feeling for details that are technically acceptable but do not fit the product you want to build.</p><p>Taste shows up in visual hierarchy, behavior, language, consistency, restraint, and hundreds of small details. But it also shows up in the benchmark you hold. It means not accepting the first plausible idea simply because producing another one is easy.</p><p>AI makes the first acceptable answer almost free, which makes it tempting to stop there. Taste shows in the willingness to keep questioning and improving that answer.</p><p>Taste cannot be fully captured in a checklist, but its consequences can be shared. A team can turn past decisions into design principles, strong defaults, component APIs, examples, naming conventions, and constraints. The rule that every view gets one primary action is one small example. Once encoded in the system, future developers and agents benefit from it without having to revisit the same discussion.</p><p><h3>Good DX Helps Agents Too</h3></p><p>The same thinking applies to developer experience. Agents write a lot of code today, but humans must still be able to read it, understand it, maintain it, and make confident decisions about it. Clear APIs, strong conventions, good names, explicit contracts, and understandable architecture make that possible.</p><p>Interestingly, the things that improve DX for humans usually improve it for agents too. A well-designed system contains less ambiguity. Agents can produce better code, navigate the codebase more reliably, and answer questions more accurately.</p><p>Software now has human and non-human users. The best experience is not identical for each group. A human might need a clear workflow and strong visual hierarchy. An agent might need predictable APIs and explicit boundaries. In both cases, good judgment begins by understanding who is using the product and designing around their needs.</p><p><h3>A Better Way to Build With AI</h3></p><p>I want us to use AI as much as possible while bringing more context, judgment, and taste to the process.</p><p>The loop I aim for looks like this:</p><p>1. Give AI the product context and constraints, not only the task.
2. Use it to explore several credible approaches.
3. Apply human judgment to choose the right trade-off.
4. Apply taste through critique and iteration until the result meets a high benchmark.
5. Encode the decision into the system so future humans and agents inherit it.</p><p>This changes the role of a product builder. More of our time can go into understanding users, translating their problems, setting the standard, and deciding what good means because AI takes on more of the implementation.</p><p><h3>There Is No Universal Best</h3></p><p>AI will become much better at proposing context-sensitive and tasteful solutions. I expect it to participate in more of these decisions over time.</p><p>But there is no universal best product. Best is different for every group of users, human or non-human. It depends on whom you choose to serve, which trade-offs you accept, and what you believe their experience should be.</p><p>As AI takes on more judgment, we will still have to choose which opinion it should follow and which outcome is worth pursuing.</p><p>That is why I am so optimistic about what comes next. Small, thoughtful teams have unprecedented leverage and can build exceptional software faster than ever before. To use that leverage well, they need to understand their users deeply, hold strong opinions, reject output that is merely plausible, and encode what they learn into the product.</p>]]></content:encoded>
    </item>
    <item>
      <title>Fast, Good, and Cheap: Why the Old Trade-Off No Longer Applies</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/fast-good-and-cheap</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/fast-good-and-cheap</guid>
      <pubDate>Fri, 20 Mar 2026 00:00:00 GMT</pubDate>
      <description>The iron triangle of software development is dead. AI has changed the math.</description>
      <content:encoded><![CDATA[<p>> This article was written as a message to my team. I'm publishing it here because I believe the ideas apply broadly to any engineering team building products today.</p><p>Team,</p><p>I want to share some thoughts on something we've been discussing internally. Every developer knows the iron triangle: fast, good, cheap. Pick two. It's been the unspoken law of software development for decades.</p><p>I think that rule is dead. And I want to explain why I believe that, because it shapes how I think we should work going forward.</p><p><h3>The Time Split Has Flipped</h3></p><p>Before AI, building a feature estimated at three days looked something like this: 20-30% of the time went into thinking. Understanding the problem, laying out a plan, figuring out how to tackle it. The remaining 70-80% was spent writing code, writing tests, wiring things together. The mechanical work dominated.</p><p>Now that ratio has completely flipped. A three-day task becomes a day and a half. But here's the important part: out of that day and a half, roughly 70% goes into thinking about how to solve the problem. The remaining 30% is spent by AI agents actually building it.</p><p>And this is exactly why quality goes up, not down. <Highlight>Thinking time is directly proportional to quality.</Highlight> When you compress the mechanical part and expand the thinking part, you don't just ship faster. You ship something better.</p><p><h3>Why We're Pushing for Speed</h3></p><p>We're building commercial products and pushing hard to ship fast. Not because we love velocity for its own sake, but because we want to get to market as quickly as possible. We want real customer feedback. Not hypothetical theories from whiteboards and meeting rooms.</p><p>The only way to get meaningful feedback is to put something real in front of paying customers. And that means we need to build momentum: ship fast and ship quality. Because shipping garbage doesn't generate useful feedback. Customers can't tell you what to improve if the product doesn't work in the first place.</p><p><h3>Execution Is No Longer the Bottleneck</h3></p><p>The biggest shift I've personally felt is that execution is no longer a constraint. When I'm speaking to customers or prospects, I can identify their pain points, find the gaps between what we offer and what they need, and turn that into a solution. Fast.</p><p>The loop between learning about a problem and delivering a fix has become incredibly tight. I'm pushing into an 80/20 mentality: ship at 80%, then let the remaining 20% be shaped by real customer feedback from actual usage.</p><p>Here's what I want all of us to internalize: don't start by thinking about how much budget or time you have to implement something. Think about how you would build it in a perfect world. And then actually do that. AI removed the ceiling. The constraint used to be "what can I afford to build?" Now it's simply "what's the best solution?"</p><p><h3>Rethinking Quality</h3></p><p>When I say "quality," I don't just mean clean code. Code quality is one dimension under a much larger umbrella. Quality also means:</p><p>- <strong>Problem-solution fit.</strong> Does this actually solve the customer's problem?
- <strong>Implementation ease.</strong> How easily can a customer adopt this for their specific use case?
- <strong>Business value.</strong> Does it serve a clear business need?</p><p>Customers don't read your code. They don't care about your abstractions or your test coverage. They care about whether the product works and whether it solves their problem. Beautiful code that solves the wrong problem isn't quality.</p><p>This is really important for us as developers to understand: code quality is just one dimension, and it needs to serve a clear business need and a clear purpose.</p><p><h3>The New Economics of Code Review and Testing</h3></p><p>This part might be controversial, and I'm fine with that.</p><p>Why did we do code reviews in the past? Because every change to the codebase was expensive. A developer had to find the issue, understand the context, make the change. And if something slipped through, fixing it later was costly. Code review was a form of insurance: catch problems early to save money later.</p><p>But that economic argument has shifted. If fixing a bug reported by a real customer costs almost nothing and takes almost no time, the ROI of heavy upfront review changes fundamentally. The same logic applies to exhaustive test suites.</p><p>I'm not saying throw all discipline out the window. There's an important distinction here: yes, we can quickly fix a reported bug now. But some bugs cause real damage that no hotfix can undo. Data loss. Security incidents. Broken trust with customers. Being able to ship a fix in minutes does nothing to repair that kind of real-world damage.</p><p>So a solid infrastructure and test setup is still essential. The obvious things need to be covered. Critical paths, data integrity, security boundaries. That's non-negotiable. What changes is how much time we spend reviewing every cosmetic detail or writing tests for edge cases that will never happen in practice.</p><p>Architecture still matters enormously and needs deep human thinking, ideally done together with AI. Describe how customers use the product, find the right patterns for today, and think about how the problem might evolve in the future. Is the architecture ready to support a changed context down the road?</p><p>But spending hours reviewing every line of code for style and minor issues? That math doesn't add up anymore.</p><p><h3>Smaller Teams, Bigger Output</h3></p><p>The most expensive part of building software was never version one. It was everything after. All the iterations, touching existing code, maintaining backwards compatibility, responding to feedback across a growing codebase.</p><p>AI drives that iteration cost toward zero. Writing code, changing code, iterating on code. It approaches a zero-sum game. And that changes how we should structure ourselves.</p><p>A smaller team is an advantage, not a compromise. We communicate better. We can be in the same room, talk to customers constantly, share learnings internally. Each person tackles a problem, comes back with improvements, distills those learnings into a knowledge base that levels up the whole company over time.</p><p>That's what I want us to be. A tight group that learns fast and ships fast.</p><p><h3>Where Confidence Really Comes From</h3></p><p>Some people hear "smaller teams, faster shipping, less code review" and think it sounds reckless. I get it. But I'd challenge where we're building our confidence.</p><p>Confidence shouldn't come from passing tests in a GitHub Action. It should come from the sentiment and feedback of our actual customers. The people paying us to use the product. If they say "this is great, this solves exactly the problems we have, this helps us in our day-to-day business," then we're doing something right.</p><p>That's real confidence. Building it on vanity metrics in a test suite or a code quality dashboard is a false sense of security.</p><p><h3>The Developer of the Future</h3></p><p>If you're a developer who's only good at coding, you're going to have a hard time. The most important skill now is the ability to talk to your users, quickly build a mental model of their problems, and extract the information you need to improve the product.</p><p>You need to translate features into benefits. You need to bridge the gap between what a user struggles with and what the product should do. You need to build these mental models fast and turn them into action.</p><p>Our role is evolving from "code writer" to "problem translator." The code is the easy part now. Understanding the problem, that's where the value is.</p><p><h3>What This Means for Us</h3></p><p>The iron triangle isn't gone. It's just that AI has made execution so cheap and fast that the triangle collapses. We can have all three: fast, good, and cheap. But only if we stop optimizing for code and start optimizing for customer outcomes.</p><p>Let's do exactly that.</p>]]></content:encoded>
    </item>
    <item>
      <title>Making Myself Irrelevant Was the Easy Part</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/making-myself-irrelevant-was-the-easy-part</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/making-myself-irrelevant-was-the-easy-part</guid>
      <pubDate>Sat, 22 Nov 2025 00:00:00 GMT</pubDate>
      <description>After delegating everything and making myself irrelevant, I had to rediscover my purpose as CEO.</description>
      <content:encoded><![CDATA[<p>From January through October 2025, I found myself in a situation that would sound absurd to my 2019 self: <Highlight>I was struggling to find my place in my own company.</Highlight> Not because the business was failing—quite the opposite. <a href="https://elevantiq.com">Elevantiq</a> was profitable, growing, with an incredible team of more than 15 people across offices in Kitzbühel and Vienna, building <a href="https://vendure.io">Vendure</a>, one of the leading open-source headless commerce frameworks in the world. The problem was that I had successfully made myself irrelevant, and I had no idea what to do with that achievement.</p><p>This article is the start of documenting my personal journey from founder to what I hope will become a <em>true</em> CEO. I'm writing this because during my research, I found surprisingly few public resources about this specific struggle that technical founders face—the ones who can code, sell, and market, but suddenly don't know where they fit anymore. If you're reading this and nodding along, this is for you.</p><p><h3>The Comfort of Exhaustion</h3></p><p>When I started Elevantiq in 2019 from rural Kitzbühel, Austria—a place dominated by tourism and construction, not exactly a tech hub, known for the famous <a href="https://www.youtube.com/watch?v=jKPukQrwIiw">Hahnenkamm Ski Race</a> and loved by the millionaires and billionaires of this world—I had zero experience building a company. But I had the energy and hunger to do everything myself. In the morning I'd be building features for clients, then jump into meetings before lunch, carve out two hours for marketing, and immediately switch to emergency client support because something went wrong. Days didn't end at 5pm, they ended at 8 or 10pm, and I was exhausted. But it felt good. I could see my to-do list getting shorter, feel the progress in my bones through sheer physical exhaustion, and know that I was moving the company forward with my own two hands.</p><p>Everyone tells you the same thing as you grow: "Make yourself irrelevant. Step out of day-to-day operations. Focus on what's 2-3 years ahead, not the now." I read this in every business book, every founder biography, every leadership article. So I built a great leadership team—<a href="https://www.linkedin.com/in/max-page/">Max</a>, who gradually took over more and more operations starting in 2021 and eventually became COO, and <a href="https://www.linkedin.com/in/michael-bromley-ab1704156/">Michael</a>, who joined our universe in 2022 through a joint venture focused on Vendure and now leads all technical work as CTO. I trusted them completely, and they delivered beyond expectations. The team was committed in ways I never forced them to be, fixing bugs late Friday evenings not because they had to, but because they cared.</p><p>Here's what made the delegation "easy" in hindsight: <Highlight>I had hired people who were strong enough to not need me.</Highlight> When my anxiety kicked in later and I pushed to reconsider our entire strategy multiple times throughout the year, Max and Michael had strong enough convictions to push back. They didn't just execute my vision—they had their own, and they defended it. The company kept moving in the right direction not because I was steering it, but because I had built a system that could steer itself. This was exactly what I wanted, and exactly what terrified me.</p><p>But this created an unexpected problem: <Highlight>What was <em>my</em> role now?</Highlight></p><p><h3>The Identity Crisis of a Technical Founder</h3></p><p>I was left with marketing and sales. I don't dislike this work—it's necessary, and I'm decent at it—but it doesn't fulfill me the way engineering does. I did it because <Highlight>I'm used to doing things I don't love when they need to get done</Highlight>, and if it wasn't me, who would it be? But the moment an opportunity to code appeared—supporting Vendure's new admin dashboard, building the new website—I'd drop everything and dive in. Then I'd realize I was avoiding what I <em>should</em> be doing, force myself back to marketing tasks, but never deliver anything that felt meaningful to me because I was constantly monitoring what was happening on the engineering side.</p><p><Highlight>For the first time in my company's life, I couldn't see the day-to-day progress.</Highlight> I wasn't in the trenches anymore, and that meant I couldn't <em>feel</em> how well we were working, how fast we were moving forward. <Highlight>My mind filled that vacuum with anxiety.</Highlight> Were we heading in the wrong direction? Was our strategy wrong? Was the competition pulling ahead? Should we change everything? The urge to throw everything up in the air was overwhelming because I convinced myself it wasn't working—not because of any real evidence, but because I couldn't see the daily wins anymore.</p><p>And then there was the deeper issue: my self-worth. <Highlight>I had this idea that I needed to be the best software engineer in the company, that everyone should look up to me for my technical skills—my speed, my ability to solve problems the moment they arose.</Highlight> When I stepped back from that, my value to the company felt like it had dropped to zero. What was I actually doing that moved us forward if I wasn't coding? The question haunted me.</p><p><h3>Taking Space to Think</h3></p><p>By November 2025, I was spinning. I told Max and Michael I needed space until the end of the year and essentially stepped away, telling the team I was focusing on a private construction project with my brother. Everyone understood. The first two weeks I did basically nothing—spent time with my girlfriend, went to the gym, read books, occasionally coded for fun. But I also started researching. Not the company's problems. <em>My</em> problem.</p><p>My research broke into three phases:
1. Are there others who experience this?
2. What is a CEO actually supposed to do?
3. And most importantly, do I really want to be a CEO?</p><p>I searched for "technical founder identity crisis" and similar terms, diving through blogs, forums, and business publications. I found maybe five articles from other technical founders describing similar experiences—not many, but enough to know I wasn't alone. Then I immersed myself in understanding what CEOs actually do: reading books on leadership transitions, listening to podcasts with founder-CEOs, watching interviews with successful technical founders who made the leap, and buying articles from Harvard Business Review on the founder-to-CEO journey. This wasn't the surface-level "set vision and strategy" stuff—I wanted to understand what world-class CEOs actually spend their time on, how they think, how they create value when they're not personally building the product.</p><p>---</p><p>Here are some of my raw notes that I took while researching:</p><p>- As founder to need to shift <em>from operator to architect</em>
- <Highlight>The founder finds value in their own output; the CEO finds value in the system's output</Highlight>
- Founders manage employees; CEOs lead leaders
- The founder is a firefighter, addicted to the urgency of the next crisis. The CEO is a strategist, focused on the five-year horizon
- Founders believe the best product wins. CEOs know the best <em>culture</em> wins, because it can build any product
- Founders equate speed with doing things themselves, CEOs see scale in systems that run independently
- As a founder your self-worth becomes a function of your to-do list
- Becoming a CEO from a founder is ultimately about pulling away from daily operations. The discipline is in resisting the lure of the urgent.
- Instead of being the best worker in the room, the founder must design the system that makes work flow without them. This is the essence of the founder to CEO transition. It is not abandoning responsibility but elevating it.
- The 5 most important shifts from founder to CEO
    - doing→designing systems
    - deciding→coaching decisions
    - managing→leading leaders
    - short-term→long-term
    - product→culture obsession
- A founder brings an idea into the world. A CEO sets direction while building and motivating an entire organization to capitalize on the opportunity.
- Disseminate Culture from the Top by holding team discussions and view it as a strategy session 
- Getting mentors for different areas is incredibly important to always self-reflect and learn
- Creating space to reflect is massively important (aka taking vacation)
- Recruiting must be a top priority - the CEO sets the tone. The people you hire are your brand.
- Create alignment by establishing overarching corporate goals and empower each "unit" of the organization to create their own under this umbrella</p><p>---</p><p>The last question took longer. I tried to remember why I started this company in the first place. I went back through old notes and emails from 2019, when I was still freelancing. What I found was revealing: I had told everyone back then that I wanted to build a team because I was tired of working alone on someone else's projects. I wanted to work with others on <em>our</em> stuff—building a team, building a company, building products, delivering unique services.</p><p><Highlight>The hilarious realization: where I am now is exactly what I dreamed of back then.</Highlight></p><p>I had achieved my first major challenge—building a profitable, stable, growing company with an incredible team. But I had no next challenge focused on me as a person, on my own growth and development. The crisis wasn't that I had failed; it was that I had succeeded at the first mountain and couldn't see the next one to climb.</p><p><h3>Redefining Value</h3></p><p>Somewhere along the way, I had gotten the equation backwards. <Highlight>I thought making myself irrelevant meant making myself worthless.</Highlight> But the dream was never to be the best engineer in the room—it was to build something bigger than myself, to have a team executing a shared vision.</p><p>I decided my next challenge would be to become the best CEO I could be—measured not by traditional metrics, but by whether I'm genuinely learning new skills and staying out of my comfort zone of software engineering, even when coding feels easier. Success means resisting the pull back to what's familiar and mastering an entirely new set of skills, behaviors, and ways of thinking.</p><p>The shift required reframing everything: My value isn't measured by my personal output anymore, but by the company's output. Founders equate speed with doing things themselves; CEOs see scale in systems that run independently of them. I need to figure out how to make these systems visible to myself—what metrics, processes, and markers of progress matter when I'm not personally shipping code. I don't have all these answers yet, and that's part of the work ahead.</p><p><h3>Going Forward</h3></p><p>My focus is shifting entirely to what only a CEO can do: Culture. Systems. Recruiting. Strategy. Vision. Alignment. I'll still code occasionally, but with clear goals and oversight from others—otherwise I know I'll relapse and disappear into the flow state for weeks.</p><p>Hiring will become a major focus, not to suddenly scale to 40 people next year, but to invest seriously in employer branding, building a network of potential talent, and establishing ourselves in Vienna's and Europe's tech scene. I'm also hiring an experienced Growth leader to take over marketing entirely so I can focus on sales—something I actually love and get amazing feedback on, partly because my technical knowledge lets me connect dots quickly and give qualified answers instead of generic sales talk.</p><p>Most importantly, I'm being open with my team about this journey. Being vulnerable about the fact that nobody is born knowing how to be a CEO, that I'm learning too, and that this is completely normal.</p><p><h3>The Fire Returns</h3></p><p>My fire is back. My hunger is back. I'm more motivated going into 2026 than I've been in a while, knowing that this year will mark the next major step—for the organization, but also for me as a person.</p><p>But this isn't a success story yet—it's the beginning of one. I don't have the systems figured out. I don't know if I'll succeed at becoming a true CEO. I might discover I hate it, or that I'm terrible at it, or that the role doesn't suit me. What I do have is clarity on the challenge ahead and the fire to pursue it. That's enough to move forward with purpose.</p><p>I plan to write a follow-up article in July 2026 to share what I've learned, what worked, what didn't, and how this journey has evolved. Taking these two months to step back and think was the best decision I've made for myself and the company in years.</p><p>I keep coming back to one insight that reminds me daily why I'm doing this:</p><p>> A founder who continues to define their value by personal effort inevitably caps growth. A CEO, by contrast, defines value by the systems they design and the leaders they empower.</p><p>If you're a technical founder struggling with similar questions about your role, your value, and your purpose as your company grows—you're not alone. And the answer might be simpler than you think: <Highlight>the dream you're living now might be exactly the one you started with. You just need a new challenge to chase.</Highlight></p><p>---</p><p><em>This is the first in a series documenting my journey from founder to CEO. Follow along at <a href="https://dlhck.com">dlhck.com</a> for updates.</em></p>]]></content:encoded>
    </item>
    <item>
      <title>Founding the TypeORM Association</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/founding-the-typeorm-association</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/founding-the-typeorm-association</guid>
      <pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate>
      <description>Our Path Forward at TypeORM</description>
      <content:encoded><![CDATA[<p>Last November, <a href="https://x.com/michlbrmly">Michael</a> and I took over maintenance of <a href="https://typeorm.io/">TypeORM</a>. Since then, we've assembled a dedicated group of maintainers, shipped 7 patch releases fixing hundreds of issues, and brought the project back to life. That was milestone one.</p><p>TypeORM stands now at more than 12M monthly downloads and 36k GitHub stars. The project is truly alive!</p><p>!<a href="/typeorm-downloads.svg">TypeORM Downloads</a></p><p>Now we're working toward v1.0.0 and our next big milestone: establishing an official TypeORM association with clear governance, committed maintainers, and sustainable funding.</p><p><h3>Why an Association in Austria?</h3></p><p>We're <Highlight>incorporating in Vienna, Austria</Highlight> for several practical reasons:</p><p>Vienna hosts numerous international non-profit organizations and institutions. Austria maintains political neutrality—important for a project open to everyone globally, regardless of geography or politics. Michael and I live here, making incorporation straightforward. The bureaucracy and costs are minimal.</p><p><h3>Our North Star</h3></p><p>We're building toward:</p><p>- <strong>Clear governance structure</strong> with a board steering technical direction and strategic partnerships
- <strong>Committed maintainers</strong> investing 2-4 hours weekly, compensated for their work so TypeORM isn't an afterthought
- <strong>Sustainable sponsorships</strong> that fund the maintenance team
- <strong>Market visibility</strong> alongside Drizzle, Prisma, and other modern ORMs
- <strong>Benefits and incentives</strong> that make sponsoring TypeORM attractive to companies and individuals</p><p><h3>The Roadmap</h3></p><p><strong>Step 1: Incorporation</strong>
Found the association with proper bylaws (~€50 incorporation cost). Elevantiq will cover upfront costs and submit invoices to OpenCollective.</p><p><strong>Step 2: Build the Board</strong>
Find a third board member responsible for technical steering—triaging, roadmap planning, releases, and community communication. Michael and I will focus on database company relationships, framework collaborations, and public communications.</p><p><strong>Step 3: Improve Financial Infrastructure</strong>
Move beyond OpenCollective's current limitations to better manage donations, funds, and payments to maintainers.</p><p><strong>Step 4: Grow the Maintenance Team</strong>
Build toward 6-8 maintainers over 18-24 months, each committing 2-4 hours weekly. Compensation will be buying-power adjusted hourly rates—not market rates, but fair reimbursement for opportunity cost.</p><p><strong>Step 5: Secure Sponsorships</strong>
Establish recurring donations from database companies, frameworks, and organizations to fund the team.</p><p><strong>Step 6: Establish Release Discipline</strong>
Create a maintainable release routine prioritizing stability over rapid innovation.</p><p><strong>Step 7: Transparency</strong>
Operate with open accounting—visible incoming sponsorships and outgoing payments.</p><p><h3>Join Us</h3></p><p><strong>Feedback</strong>: What are we missing? What could be better? <a href="mailto:maintainers@typeorm.io?subject=Association-Founding-Feedback">Let us know</a>.</p><p><strong>Third Board Seat</strong>: We're reaching out to candidates who've shown strong commitment over recent months.</p><p><strong>Maintenance Team</strong>: Interested in joining the official maintenance team? <a href="mailto:maintainers@typeorm.io?subject=Maintainer-Application">Email your application</a> and tell us why you should be part of TypeORM's future.</p><p><strong>Sponsorships</strong>: Work at a database company interested in collaboration? Have budget for open source sponsorships? <a href="mailto:maintainers@typeorm.io?subject=Sponsorship">Reach out to us</a>.</p><p>TypeORM has been a cornerstone of the TypeScript ecosystem. Together, we're building the foundation for its next chapter.</p>]]></content:encoded>
    </item>
    <item>
      <title>The Complete Guide to Self-Hosting Next.js at Scale</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/the-complete-guide-to-self-hosting-nextjs-at-scale</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/the-complete-guide-to-self-hosting-nextjs-at-scale</guid>
      <pubDate>Sun, 24 Aug 2025 00:00:00 GMT</pubDate>
      <description>A comprehensive guide to self-hosting Next.js in production with horizontal scaling, covering critical solutions for distributed caching, image optimization, reverse proxy configuration, and deployment challenges learned from real-world experience.</description>
      <content:encoded><![CDATA[<p>After years of running Next.js applications serving thousands of users at <a href="https://elevantiq.com" target="_blank">Elevantiq</a>, I've learned that self-hosting Next.js in production
is fundamentally different from clicking "deploy" on Vercel. When you're dealing with horizontal scaling, multiple replicas, and enterprise-grade requirements,
the default Next.js setup breaks down in ways that aren't immediately obvious.</p><p>This guide contains <Highlight>every hard-won lesson</Highlight> from deploying and maintaining Next.js applications at scale. Whether you're using Kubernetes, Docker Swarm, or platforms like Northflank and Railway, these solutions will save you from the production challenges I've already faced.</p><p><h3>The Hidden Challenge: Why Next.js Breaks at Scale</h3></p><p>Here's what nobody tells you about self-hosting Next.js: <strong>the framework assumes it's running as a single instance</strong>. The moment you spin up multiple replicas for high availability (which you absolutely need in production), everything that touches the filesystem becomes a problem.</p><p>Next.js loves writing to disk. Cache files, optimized images, temporary data, it's all stored locally in <code>.next/cache</code>. This works perfectly on Vercel because they abstract this complexity away. But when you have three replicas running simultaneously, you get this challenging scenario:</p><p>- User hits replica 1: Cache miss, generates content, stores locally
- Same user hits replica 2: Cache miss again, regenerates identical content
- Result: Inconsistent performance, wasted resources, confused users</p><p>This guide covers six critical areas where Next.js needs special configuration for production self-hosting: Dockerfiles, reverse proxies, caching, image optimization, CDNs, and server actions. Get any of these wrong, and your application may not function as expected in production, often in ways that only appear under load.</p><p><h3>Important Context</h3></p><p>It's worth noting that Next.js documentation states that ISR and caching work "automatically when self-hosting" with <code>next start</code>. The challenges we're addressing here primarily emerge when:</p><p>- You need horizontal scaling with multiple replicas
- You're operating at significant scale (thousands of concurrent users)
- You require zero-downtime deployments
- You have strict performance SLAs</p><p>For smaller deployments or single-instance setups, many of these issues won't apply.</p><p><h3>A Note on Context and Scope</h3></p><p>This guide is based on real-world experience deploying Next.js applications serving thousands of concurrent users in enterprise e-commerce environments. The solutions presented here address challenges that primarily emerge at scale with:</p><p>- Multiple replica deployments
- Kubernetes or similar orchestration
- Strict performance and availability requirements
- Complex caching needs</p><p>Many of these issues are standard distributed systems challenges that aren't unique to Next.js. The framework handles many scenarios well out of the box, especially for single-instance deployments. These solutions are for when you need to go beyond the default setup.</p><p>Performance metrics mentioned are from production systems under NDA and will vary significantly based on your specific implementation, infrastructure, and traffic patterns.</p><p><h3>1. Production-Ready Dockerfiles: The Foundation</h3></p><p>Start with the <a href="https://github.com/vercel/next.js/blob/canary/examples/with-docker-multi-env/docker/production/Dockerfile">official Next.js multi-stage Dockerfile</a>, but don't use it as-is. Here are the essential modifications:</p><p><code>`</code>dockerfile
<h3>In your base stage</h3>
ENV NEXT_TELEMETRY_DISABLED=1</p><p><h3>Add health checks for zero-downtime deployments</h3>
EXPOSE 3000
HEALTHCHECK --interval=12s --timeout=12s --start-period=5s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000 || exit 1
<code>`</code></p><p>:::info
Health checks are critical for zero-downtime deployments, but can be tricky to get right, as they might lead to restart loops. Please verify that health checks are working before deploying.</p><p>If you're using a platform like Northflank or Railway, they might have a health check feature that you can use. If not, you can use a simple HTTP health check like the one above.
:::</p><p><h3>Why Health Checks Matter More Than You Think</h3></p><p>Without proper health checks, your orchestrator doesn't know when a replica is ready to serve traffic. During deployments, this causes:</p><p>- Request failures as traffic routes to starting containers
- Downtime when rolling updates kill healthy replicas before new ones are ready
- Zombie containers that crashed but still receive traffic</p><p>The health check configuration above ensures:</p><p>- New replicas are fully started before receiving traffic
- Crashed replicas are detected and replaced within x seconds
- Zero-downtime deployments actually achieve zero downtime</p><p><h3>2. Reverse Proxy Configuration: The Streaming Killer</h3></p><p>Your reverse proxy or ingress controller (Traefik, NGINX, HAProxy, Kong) needs specific configuration for Next.js. The critical requirement: <strong>disable response buffering</strong>.</p><p>Without this, React Suspense and streaming responses may not function as expected. Your users see blank pages or experience massive delays as the proxy buffers the entire response before sending it.</p><p><h3>NGINX Configuration</h3></p><p>Add this header in your <code>next.config.js</code>:</p><p><code>`</code>javascript
module.exports = {
  async headers() {
    return [
      {
        source: "/:path*{/}?",
        headers: [
          {
            key: "X-Accel-Buffering",
            value: "no",
          },
        ],
      },
    ];
  },
};
<code>`</code></p><p><h3>Traefik with Docker Swarm</h3></p><p><code>`</code>yaml
labels:
  - "traefik.http.middlewares.nobuffering.buffering.maxResponseBodyBytes=0"
  - "traefik.http.routers.myservice.middlewares=nobuffering"
<code>`</code></p><p>This single configuration issue has caused more production incidents than any other in my experience. Test streaming responses explicitly before going live.</p><p><h3>3. Distributed Caching with Redis: The Filesystem Alternative</h3></p><p>The default filesystem cache is completely incompatible with horizontal scaling. You have three options:</p><p>1. <strong>Shared volume</strong> (doesn't work): File locking issues, race conditions, data corruption
2. <strong>Master-slave setup</strong> (challenging): Requires complex coordination to ensure only designated instances write to cache, which can limit write throughput
3. <strong>Redis</strong> (works perfectly): Centralized, fast, battle-tested</p><p><h3>Official Cache Handler Approach</h3></p><p>The Next.js documentation provides an example of creating a custom cache handler. Here's the official approach:</p><p><code>`</code>javascript
// From Next.js official documentation
module.exports = {
  cacheHandler: require.resolve("./cache-handler.js"),
  cacheMaxMemorySize: 0, // disable default in-memory caching
};
<code>`</code></p><p>While you can implement your own cache handler following the official documentation pattern, I strongly recommend <a href="https://github.com/trieb-work/nextjs-turbo-redis-cache">@trieb.work/nextjs-turbo-redis-cache</a> for its production-ready features. The official docs even provide a Redis example that you can adapt to your needs.</p><p>_Note: This is a third-party solution we've found reliable in production. It's not officially endorsed by Vercel/Next.js. Always evaluate third-party packages for your security and compliance requirements._</p><p>Basic setup:</p><p><code>`</code>javascript
const nextConfig = {
  cacheHandler: require.resolve("@trieb.work/nextjs-turbo-redis-cache"),
  cacheMaxMemorySize: 0, // Disable in-memory caching
};
<code>`</code></p><p><h3>Critical Warning: The Monorepo Trap</h3></p><p>If you're using a monorepo (Nx, Turborepo, etc.), <code>require.resolve</code> can cause connection failures. The cache handler file gets duplicated during build, breaking the singleton pattern. Solution:</p><p><code>`</code>javascript
const path = require("node:path");
const CopyPlugin = require("copy-webpack-plugin");</p><p>const nextConfig = {
  cacheHandler: path.join(__dirname, ".next/server/cache-handler.js"), // Absolute path
  cacheMaxMemorySize: 0,
  webpack: (config, { isServer }) => {
    if (isServer) {
      config.plugins.push(
        new CopyPlugin({
          patterns: [
            {
              from: "./cache-handler.js",
              to: "./cache-handler.js",
            },
          ],
        })
      );
    }
    return config;
  },
};
<code>`</code></p><p><h3>Performance Optimization: Cache Size Matters</h3></p><p>In our experience with large e-commerce deployments, we discovered that caching full API responses led to slower Redis read times. The solution:</p><p>- Pre-process data before caching
- Only cache essential fields
- Monitor cache item sizes (we target under 1MB based on our infrastructure)
- Monitor Redis memory usage constantly</p><p>Your optimal cache size will depend on your Redis configuration, network latency, and data structure.</p><p>Also, be extremely careful with data passed from server to client components. Large prop sets create:</p><p>- Massive cache entries
- Huge DOM sizes
- Slow hydration
- Poor Core Web Vitals</p><p><h3>4. Image Optimization: External Processing is Non-Negotiable</h3></p><p>Next.js's built-in Sharp-based image optimizer stores resized images on the filesystem. With multiple replicas, every instance processes the same images independently. This is wasteful and slow.</p><p><h3>Solution 1: Image Transformation Services</h3></p><p>Use <a href="https://imagekit.io">ImageKit</a>, <a href="https://techdocs.akamai.com/ivm/docs/imquery">Akamai</a>, or similar:</p><p>_Note: These are third-party services. Always evaluate them for your security, compliance, and cost requirements._</p><p><code>`</code>javascript
const customLoader = ({ src, width, quality }) => {
  return `https://cdn.your-company.com/transform?url=${src}&w=${width}&q=${
    quality || 75
  }`;
};</p><p>module.exports = {
  images: {
    loader: "custom",
    loaderFile: "./image-loader.js",
  },
};
<code>`</code></p><p><h3>Solution 2: Self-Hosted with IPX</h3></p><p>Deploy <a href="https://github.com/unjs/ipx">ipx</a> as a separate service:</p><p>_Note: IPX is a third-party open-source solution. Always evaluate third-party packages for your security and compliance requirements._</p><p>Benefits:</p><p>- Centralized image cache shared across all replicas
- Reduced memory usage in Next.js containers
- CDN-ready with proper cache headers
- Consistent performance across all instances</p><p><h3>5. CDN Configuration: Cache-Control is Everything</h3></p><p>A CDN dramatically improves performance, but misconfiguration breaks your application. The golden rule: <strong>Your CDN must respect the origin's Cache-Control headers</strong>.</p><p>Next.js sets different cache headers based on:</p><p>- <code>export const revalidate = 3600</code>
- Dynamic routes
- Authentication state
- Cookie presence</p><p>If your CDN ignores these headers, you'll serve stale content to logged-in users or cache personalized pages publicly.</p><p><h3>Testing Checklist</h3></p><p>Before production:</p><p>1. Verify static assets are cached (CSS, JS bundles)
2. Test that <code>revalidate</code> values are respected
3. Confirm dynamic routes bypass cache appropriately
4. Validate authenticated requests aren't cached
5. Check cache invalidation works as expected</p><p><h3>6. Server Actions: The Deployment Consistency Challenge</h3></p><p>Server Actions use encrypted identifiers that change with every build by default. During rolling deployments, this causes the dreaded error:</p><p>:::error
"Failed to find Server Action "XYZ". This request might be from an older or newer deployment."
:::</p><p><h3>The Fix</h3></p><p>Set a consistent encryption key per environment:</p><p><code>`</code>bash
<h3>In your .env file</h3>
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-32-character-key-here
<code>`</code></p><p>Generate different keys for each environment (dev, staging, production) but keep them consistent across deployments within that environment.</p><p><h3>Security Consideration for Server Actions</h3></p><p>It's crucial to understand that according to the Next.js documentation, Server Actions "create a public HTTP endpoint and should be treated with the same security assumptions." This means:</p><p>- Always validate and authorize within your Server Actions
- Treat them like public API endpoints
- Never rely solely on encryption for security
- Implement proper authentication and authorization checks</p><p>The encryption key consistency we discussed above helps with deployment, but is not a security feature by itself.</p><p><h3>Real-World Performance Results</h3></p><p>After implementing these solutions in large-scale enterprise commerce projects:</p><p>- <strong>Response times</strong>: Significant reduction for cached content (specific metrics vary by implementation)
- <strong>Server load</strong>: Substantial decrease during peak traffic
- <strong>Deployment failures</strong>: Zero-downtime achieved consistently
- <strong>User experience</strong>: Eliminated inconsistent page load times</p><p>Note: These results are from enterprise deployments under NDA. Your results will vary based on traffic patterns, infrastructure, and implementation details.</p><p><h3>Your Production Checklist</h3></p><p>Before deploying self-hosted Next.js at scale:</p><p>- Multi-stage Dockerfile with health checks configured
- Reverse proxy with disabled buffering verified
- Redis cache handler installed and tested under load
- External image optimization service configured
- CDN respecting Cache-Control headers validated
- Server Actions encryption key set consistently
- Load testing completed with multiple replicas
- Monitoring for cache hit rates implemented
- Alerting for replica health configured
- Rollback strategy tested</p><p><h3>Conclusion</h3></p><p>Self-hosting Next.js at scale is absolutely achievable, but it requires understanding and solving these architectural challenges upfront. Every issue I've outlined here cost us hours or days of debugging in production. Learn from our mistakes.</p><p>The solutions in this guide are battle-tested with thousands of concurrent users. They work. But remember: production is where theory meets reality. Monitor everything, test thoroughly, and always have a rollback plan.</p><p>If you're implementing these solutions and hit edge cases I haven't covered, I'd love to hear about them. The Next.js ecosystem evolves rapidly, and sharing knowledge helps us all build better production systems.</p>]]></content:encoded>
    </item>
    <item>
      <title>First Article</title>
      <link>https://website-d20cpcoms-dlhck-personal.vercel.app/essays/first-article</link>
      <guid isPermaLink="true">https://website-d20cpcoms-dlhck-personal.vercel.app/essays/first-article</guid>
      <pubDate>Fri, 11 Jul 2025 00:00:00 GMT</pubDate>
      <description>Every journey starts with a first step.</description>
      <content:encoded><![CDATA[<p>I've had the idea of sharing my thoughts for a while now.</p><p>But I never found the time or the motivation to actually build my own website (again).</p><p>Building such a simple website is not tickling my brain enough to get me excited.</p><p>And I need excitement to get me going.</p><p>Thanks to Cursor and Next.js I was able to build this website in a few hours.</p><p>This article is just a memory for myself to remind me when I have started to write again.
Of course I already have some ideas for articles swirling around in my head, so let's hope that I'll stick with it.</p><p>Why do I want to write? Well, there are a few reasons and all of them are egotistical to some degree.</p><p>- I want to share my thoughts and ideas with the world.
- I want to improve my writing skills.
- I want to improve my thinking skills.
- I want to improve my communication skills.
- I want to improve my problem solving skills.
- I want to improve my decision making skills.
- I want to improve my leadership skills.</p><p>I'm not sure if I'll continue to write here, but I'm glad I took the first step.</p>]]></content:encoded>
    </item>
  </channel>
</rss>