📦 Monorepo Architecture with pnpm Workspace, Turborepo & Changesets

pnpm workspace setup

When you’re developing a project with multiple packages, managing each one in its own repo can quickly turn into a nightmare. In this article, we’ll set up a monorepo architecture from scratch using pnpm workspace, speed up build processes with Turborepo, and build an automated NPM publish pipeline with Changesets.

🏗️ What Is a Monorepo?

Let’s say you’re building a design system. You have a core package, a theme package, and a utils package. Now imagine keeping all of these in separate repositories.

When you fix a bug in the core package, what happens? You switch to the theme repo and update the dependency. Then you switch to the utils repo. You open separate PRs for each, wait for separate CI/CD pipelines, and publish separately. Even with just three packages, this process is exhausting — with ten, it’s a total nightmare.

This is exactly where the monorepo comes in.

A monorepo is an architectural approach that houses multiple projects under a single repository. All your packages live in the same repo, share the same commit history, and can easily reference each other.

So why does this matter? Let’s look at a few key advantages:

★ Code sharing in one place: Shared utilities, types, and configurations are instantly available to all packages. No need to wait for an NPM publish.

★ Atomic changes: You can make a change that affects multiple packages in a single commit. No more cross-repo PR synchronization headaches.

★ Consistent tooling: ESLint, Prettier, and TypeScript configurations are managed from a single source. The question “Why does this repo have different rules?” becomes a thing of the past.

★ Easy onboarding: When a new developer joins the project, a single git clone and pnpm install brings up the entire ecosystem.

🔧 Setting Up a pnpm Workspace

pnpm workspace setup

pnpm stands out among Node.js package managers for its monorepo support. Unlike npm and yarn, its content-addressable store keeps all dependencies in a global store and links them into your projects via hard links. This saves a massive amount of disk space.

Let’s walk through setting up a monorepo architecture with pnpm workspace using a sample project.

Step 1: Install pnpm and Initialize the Project

Install pnpm globally:

npm install -g pnpm

Then initialize a new monorepo project:

mkdir my-design-system
cd my-design-system
pnpm init
git init

★ The pnpm init command creates a package.json file at the root level.

★ This file will serve as the main entry point for your monorepo. All workspace scripts will be managed from here.

Step 2: Create pnpm-workspace.yaml

Create a pnpm-workspace.yaml file in the project root:

packages:
  - "packages/*"
  - "apps/*"
  - "website"

★ The packages/* expression defines each folder under the packages directory as a separate workspace package.

★ apps/* includes applications, and website adds the documentation site to the workspace.

★ Without this file, pnpm won’t recognize your project as a monorepo.

Step 3: Configure the Root package.json

Configure the root package.json for the monorepo:

{
  "name": "my-design-system",
  "private": true,
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "clean": "turbo run clean"
  },
  "devDependencies": {
    "turbo": "^2.3.1",
    "typescript": "^5.7.2",
    "tsup": "^8.3.5",
    "vitest": "^2.1.6",
    "prettier": "^3.4.1"
  },
  "packageManager": "[email protected]",
  "engines": {
    "node": ">=18.0.0"
  }
}

★ "private": true is a critical setting. It prevents the root package from being accidentally published to NPM.

★ The "packageManager" field ensures that everyone who clones the project uses the same pnpm version. Corepack reads this field and automatically activates the correct package manager.

★ Shared devDependencies (like TypeScript and Prettier) are installed at the root, so individual packages don’t need to install them separately.

Step 4: Create the Project Structure

Now let’s create the folder structure:

mkdir -p packages/core
mkdir -p packages/utils
mkdir -p packages/theme
mkdir -p apps/docs

A typical monorepo structure looks like this:

my-design-system/
├── packages/
│   ├── core/               # Core library
│   ├── utils/              # Shared utility functions
│   └── theme/              # Theme and style definitions
├── apps/
│   └── docs/               # Documentation app
├── .changeset/             # Changesets configuration
├── .github/                # CI/CD workflows
├── package.json            # Root workspace config
├── pnpm-workspace.yaml     # Workspace definition
├── turbo.json              # Turborepo pipeline
├── tsconfig.base.json      # Shared TypeScript config
└── pnpm-lock.yaml          # Lock file

★ Libraries and shared modules live under packages/. Each one can be published as an independent npm package.

★ Applications that consume these packages go under apps/.

★ Configuration files at the root apply across the entire monorepo.

You also need to initialize each sub-package. For example, for packages/core:

cd packages/core
pnpm init

This creates a package.json for each package. Using namespaced package names is a good practice:

{
  "name": "@myds/core",
  "version": "0.1.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsup src/index.ts --format cjs,esm --dts",
    "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
  }
}

★ Scoped names like @myds/core prevent namespace collisions on NPM.

★ The main and types fields ensure that other packages resolve the correct files when importing this package.

★ tsup is a great tool for quickly bundling TypeScript packages. It produces output in both CJS and ESM formats.

🔗 Cross-Package Dependency Management with the workspace Protocol

pnpm workspace* protocol

One of the most powerful features of a monorepo is the ability for packages to reference each other locally. pnpm achieves this through the workspace: protocol.

Let’s make this concrete with an example. Say the @myds/theme package depends on @myds/core. You’d define this dependency like so:

{
  "name": "@myds/theme",
  "version": "0.4.0",
  "dependencies": {
    "@myds/core": "workspace:*"
  }
}

★ The workspace:* expression tells pnpm: “Don’t download this package from the npm registry — use the local version from the workspace.”
★ During development, packages are linked together via symlinks. Any change you make in core is instantly reflected in theme.

But what happens at publish time? Here’s the beauty of pnpm: when you run pnpm publish, workspace:* is automatically converted to the actual version number.

So while package.json looks like this during development:

{
  "dependencies": {
    "@myds/core": "workspace:*"
  }
}

It becomes this when published to NPM:

{
  "dependencies": {
    "@myds/core": "0.4.0"
  }
}

Variants of the workspace Protocol

pnpm offers several workspace protocol variants:

★ workspace:* is the most commonly used variant. It means “use whatever version is currently in the workspace.” At publish time, it resolves to an exact version (e.g., "0.4.0").

★ workspace:^ resolves to a caret range at publish time (e.g., "^0.4.0"). This allows minor and patch updates.

★ workspace:~ resolves to a tilde range at publish time (e.g., "~0.4.0"). This allows only patch updates.

Here’s my take on when to use which: if you always publish all packages together, workspace:* is sufficient. But if you want consumers to be able to mix different versions, workspace:^ offers more flexibility.

Adding Dependencies Between Packages

To add one workspace package as a dependency of another, use the --filter flag:

pnpm add @myds/core --filter @myds/theme

★ The --filter flag runs the command only in the specified package.

★ pnpm automatically adds the dependency as workspace:^ (if it exists in the workspace).

You can also run scripts across all dependents of a specific package:

# Build @myds/core and all packages that depend on it
pnpm --filter "@myds/core..." build

To add a dependency to the root workspace, use the --workspace-root (or -w for short) flag:

pnpm add -D typescript --workspace-root

⚡ Build Orchestration with Turborepo

turborepo

While pnpm workspace alone is sufficient for package management and linking, build processes get more complex as the number of packages grows. Questions like “Which package should be built first?” and “Do we really need to rebuild packages that haven’t changed?” start to come up.

This is exactly where Turborepo steps in. Turborepo is a build system developed by Vercel that works directly with pnpm workspace. It reads pnpm’s workspace structure, analyzes the dependency graph between packages, and optimizes tasks accordingly. It has two core features:

★ Smart task scheduling: It analyzes the dependency graph between packages and automatically determines the build order. Packages that don’t depend on each other are run in parallel.

★ Hard caching: If a package hasn’t changed, it restores the previous build output from cache. While the first build might take 30 seconds, a cached build can complete in 0.2 seconds.

Let’s Set Up Turborepo

Add turbo to the root package.json:

pnpm add -D turbo --workspace-root

★ The --workspace-root flag (or -w for short) installs the package at the root workspace, making turbo available across the entire monorepo.

turbo.json Configuration

Create a turbo.json file at the project root:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "test:watch": {
      "cache": false,
      "persistent": true
    },
    "lint": {
      "outputs": []
    },
    "clean": {
      "cache": false
    },
    "check-types": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}

Let’s pause here and understand something important: the ^ in "dependsOn": ["^build"] is crucial. It means “run my dependencies’ build task first, then build me.” So before @myds/theme is built, its dependency @myds/core gets built first.

★ "outputs": ["dist/**"] tells Turborepo which files to cache. On the next build, if the source code hasn’t changed, the dist/ folder is restored from cache.

★ "cache": false disables caching for certain tasks. Caching doesn’t make sense for long-running (persistent) tasks like dev servers.

★ "persistent": true keeps the task running in the background. This is necessary for scenarios like dev servers and watch mode.

Now you can build all packages with a single command:

pnpm build
# This runs turbo run build
# Turborepo analyzes the dependency graph
# Builds in order: first core, then theme, then utils...
# Restores unchanged packages from cache

🦋 Version Management with Changesets

Changesets

One of the trickiest aspects of a monorepo is version management. You have multiple packages, each potentially with its own independent version number. How do you decide which one to bump and when, or how to generate the changelog?

This is exactly where Changesets comes in. Changesets is a versioning tool that lets you record each change as a small markdown file.

Step 1: Install Changesets

pnpm add -D @changesets/cli --workspace-root
pnpm changeset init

★ The changeset init command creates a .changeset/ directory in your project.
★ Inside this directory, you’ll find a config.json and a README.md file.

Step 2: Configure Changesets

Configure the .changeset/config.json file for your project:

{
  "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
  "changelog": [
    "@changesets/changelog-github",
    { "repo": "your-org/your-monorepo" }
  ],
  "commit": false,
  "fixed": [],
  "linked": [["@myds/*"]],
  "access": "public",
  "baseBranch": "master",
  "updateInternalDependencies": "patch",
  "ignore": []
}

Let’s break down each of these settings:

★ In the changelog section, we’re using @changesets/changelog-github. This automatically adds PR links and contributor information to the changelog. Let’s install this package as well:

pnpm add -D @changesets/changelog-github --workspace-root

★ The linked field is very important. [["@myds/*"]] tells Changesets that the versions of all packages under the @myds scope are linked together. If one gets a major bump, they all do.

★ "access": "public" ensures that scoped packages (like @myds/core) are published as public on NPM. Without this setting, scoped packages default to private and will throw an error during publish.

★ "updateInternalDependencies": "patch" automatically applies a patch bump to the internal dependency versions of packages that depend on an updated package.

★ "baseBranch": "master" determines which branch Changesets uses as its baseline. Depending on your project, this could be main or master.

Step 3: Create a Changeset

When you make a change, create a changeset file before opening a PR:

pnpm changeset

This launches an interactive wizard:

🦋  Which packages would you like to include?
  ◯ @myds/core
  ◯ @myds/theme
  ◯ @myds/utils

🦋  Which packages should have a major bump?
🦋  Which packages should have a minor bump?

🦋  Summary: Added dark mode support to the core package

This wizard creates a randomly named markdown file in the .changeset/ directory:

---
"@myds/core": minor
"@myds/theme": patch
---

Added dark mode support to the core package. Updated related color variables in the theme package.

★ The frontmatter section specifies which package gets bumped at which level.
★ The text below becomes the description added to the changelog.
★ This file is included in the commit and submitted for review along with the PR.

Step 4: Apply the Versions

Once all changesets have been collected, apply the versions:

pnpm changeset version

★ This command reads all changeset files.
★ Updates the version numbers in the relevant packages’ package.json files.
★ Automatically creates/updates each package’s CHANGELOG.md.
★ Deletes the consumed changeset files.

Step 5: Publish

After versions have been applied, publish:

pnpm changeset publish

This publishes all changed packages to the NPM registry. But are you going to do this manually every time? Of course not! In the next section, we’ll automate this entire process 😁

🚀 Automated NPM Publishing with GitHub Actions

auto npm pusblish with github actions

Enough theory — time to get our hands dirty! 🛠️ The real power of Changesets shines when combined with GitHub Actions. Let’s build a CI/CD pipeline step by step.

Step 1: Create an NPM Token

First, you need to create an Automation Token on NPM:

★ Go to your profile settings on npmjs.com.

★ In the Access Tokens section, select Generate New Token.

★ Set the token type to Automation. This type allows publishing without requiring 2FA.

★ Copy the generated token (it’s only shown once!).

Step 2: Add Tokens to GitHub Secrets

In your GitHub repo, go to Settings > Secrets and variables > Actions and add the following secret:

★ NPM_TOKEN: The NPM automation token you just created.

GITHUB_TOKEN is automatically provided by GitHub for every workflow run, so you don’t need to set it up manually.

Step 3: Configure Repository Permissions

In your GitHub repo, go to Settings > Actions > General:

★ Under Workflow permissions, enable the “Read and write permissions” option.

★ Check the “Allow GitHub Actions to create and approve pull requests” checkbox.

Without these settings, the Changesets bot won’t be able to create PRs.

Step 4: Create the Release Workflow File

Now create the .github/workflows/release.yml file:

name: Release

on:
  push:
    branches:
      - master

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: write
  pull-requests: write
  packages: write
  id-token: write

jobs:
  release:
    name: Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repo
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "pnpm"
          registry-url: "https://registry.npmjs.org"

      - name: Install Dependencies
        run: pnpm install --frozen-lockfile

      - name: Build All Packages
        run: pnpm build

      - name: Create Release Pull Request or Publish
        id: changesets
        uses: changesets/action@v1
        with:
          commit: "chore: update versions"
          title: "chore: update versions"
          publish: pnpm run ci:publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

There’s a lot going on in this workflow. Let’s break it down piece by piece:

★ The concurrency setting prevents multiple release workflows from running simultaneously. If a new push comes in while a publish is in progress, the old workflow isn’t cancelled — instead, the new one waits in the queue.

★ The permissions block gives the workflow the authority to write to the repo, create PRs, and publish packages.

★ fetch-depth: 0 pulls the full git history. Changesets needs the commit history to calculate versions.

★ pnpm/action-setup@v4 installs pnpm in the CI environment. It reads the packageManager field from package.json to install the correct version.

★ --frozen-lockfile is a critical flag for CI. It installs the exact same dependencies as specified in the lock file. If pnpm-lock.yaml is out of date, it throws an error.

★ changesets/action@v1 is the action that does all the heavy lifting. It handles two different scenarios:

Scenario A: If there are pending changeset files in the .changeset/ directory, it creates or updates a “Version Packages” PR. This PR contains the version bumps and changelog updates.

Scenario B: If there are no pending changesets (i.e., after the Version Packages PR has been merged), it runs the publish command to publish the packages to NPM.

Step 5: Define the ci:publish Script

Add the publish script to the root package.json:

{
  "scripts": {
    "ci:publish": "pnpm publish -r --access public"
  }
}

★ The -r flag (recursive) publishes all workspace packages.
★ --access public ensures that scoped packages are published as public.

Changeset Bot

Optionally, you can install the Changeset Bot from github.com/apps/changeset-bot on your repo. This bot checks whether PRs include a changeset file and leaves a warning comment if they don’t.

🎯 Demo: The Tuvix.js Monorepo Project

If you’d like to see the entire architecture described in this article in action, check out the Tuvix.js project I’ve been developing. Tuvix.js is a lightweight micro frontend framework that uses exactly the stack covered in this article:

★ pnpm workspace managing 14+ packages in a single repo

★ Turborepo for build orchestration and caching

★ Changesets for version management and automated NPM publishing

★ GitHub Actions for the CI/CD pipeline

You can explore the pnpm-workspace.yaml, turbo.json, .changeset/config.json, and GitHub Actions workflow files directly in the repository.

Check out the repo here 👇

github.com/yasinatesim/tuvix.js

📬 Feedback

While writing this article, I used my own notes for identifying sources and research, the Claude Opus 4.6 model for proofreading and additional research, and the Gemini 3 Pro Preview 2k (Nano Banana Pro) model for generating images.

I welcome any advice, suggestions, or feedback on this article. If you’d like to get in touch, you can reach me through my social media links on my website or via LinkedIn.

Best, Yasin 🤗

📚 Resources Used While Writing This Article

  1. pnpm Workspaces — Official documentation for pnpm’s workspace feature
  2. Using Changesets with pnpm — Guide on Changesets integration with pnpm
  3. Turborepo – Structuring a Repository — Turborepo’s monorepo structuring documentation
  4. Changesets GitHub Action — Official Changesets GitHub Action repository and usage guide
  5. Tuvix.js GitHub Repository — My micro frontend framework, this is monorepo project referenced in this article

Reimplementing COMMAND.COM in Go for Unix-like Platforms: Challenges and Solutions

cover

Introduction

Reimplementing COMMAND.COM in Go for Unix-like platforms is more than a nostalgic exercise—it’s a technical bridge between legacy DOS functionality and modern systems. At its core, this project addresses a critical problem: legacy software like COMMAND.COM risks becoming inaccessible on contemporary hardware and operating systems. By leveraging Go’s cross-platform capabilities, the reimplementation ensures that DOS commands and batch file processing can run natively on Unix-like systems, eliminating the need for full virtualization. This preserves both the historical value of DOS and its practical utility for education, experimentation, and niche applications.

The Technical Challenge: Parsing and Executing DOS Commands in Go

The heart of COMMAND.COM lies in its parser, a context-aware system that tokenizes input, interprets internal commands, and delegates external commands to the host system. Go’s simplicity and robust standard library make it an ideal choice for this task. However, the parser’s complexity—handling quirks like command chaining (&, &&, ||), redirection (>, <), and pipes (|)—requires careful implementation. For instance, Go’s lack of direct equivalents for DOS-specific functionalities (e.g., %PATH% expansion) forces developers to build custom solutions, such as emulating DOS environment variables and batch file processing. This involves abstracting system calls and file path handling to ensure compatibility across Unix-like systems, where file system semantics (case sensitivity, path separators) differ from DOS.

Balancing Fidelity and Modernization

A key tension in this project is balancing historical accuracy with modern usability. DOS’s forgiving nature with invalid commands—often producing cryptic but consistent error messages—must be replicated while ensuring the interpreter meets contemporary security and performance standards. For example, Go’s concurrency features could optimize command pipelines, but this deviates from DOS’s single-threaded behavior. Developers must decide whether to modernize such behaviors or preserve them for authenticity. This decision impacts not only performance but also the educational value of the project, as it serves as a living artifact of computing history.

Risks and Edge Cases: Where Things Break

Reimplementing COMMAND.COM is not without risks. Incorrect command parsing, particularly with nested quotes or special characters, can lead to unpredictable behavior. Batch file execution errors, such as mishandling line endings or encoding, are another common pitfall. Additionally, DOS commands reliant on Windows-specific APIs may fail on Unix-like systems, requiring workarounds or exclusions. Performance bottlenecks, especially with large batch files or complex pipelines, can arise if the interpreter is not optimized. These risks highlight the need for robust error handling and debugging mechanisms, with informative messages to guide users through unsupported features or behaviors.

Why Go? A Comparative Analysis

Go’s selection as the implementation language is no accident. Its cross-platform compatibility layer abstracts system-specific details, making it easier to map DOS commands to Unix-like environments. Compared to languages like C or Python, Go strikes a balance between performance and ease of use. While C offers low-level control, its complexity increases the risk of errors; Python, though simpler, lacks Go’s efficiency for system-level tasks. Go’s concurrency model, though not strictly necessary for DOS emulation, provides a future-proofing opportunity for optimizing command pipelines. The rule here is clear: if cross-platform compatibility and performance are priorities, use Go.

The Broader Impact: Preserving Computing History

Beyond technical achievements, this project serves as a bridge between historical computing and modern development. By making COMMAND.COM accessible on Unix-like systems, it fosters a deeper understanding of DOS’s design philosophy and the evolution of command-line interfaces. The open-source nature of the project invites collaboration from retrocomputing enthusiasts, ensuring its longevity and relevance. However, licensing and legal constraints must be carefully navigated to avoid intellectual property issues, particularly for open-source distribution. This project is not just about code—it’s about preserving a piece of computing history for future generations.

Technical Approach and Design

Reimplementing COMMAND.COM in Go for Unix-like platforms required a meticulous blend of architectural decisions, leveraging Go’s strengths, and addressing the unique challenges of DOS emulation. The core strategy revolved around parsing and executing DOS commands, emulating DOS-specific functionalities, and ensuring cross-platform compatibility. Here’s how it was achieved, grounded in the analytical model.

Parsing and Executing DOS Commands

The heart of the reimplementation lies in the parser, which tokenizes user input and interprets commands. Unlike a simple tokenizer, the parser is context-aware, handling command chaining (&, &&, ||), redirection (>, <), and pipes (|). Go’s robust standard library, particularly its text processing capabilities, made this feasible. For instance, the parser uses Go’s strings and bufio packages to split input into tokens while preserving the nuances of DOS syntax, such as nested quotes and special characters.

However, the parser’s complexity is a double-edged sword. Edge cases, like improperly nested quotes or ambiguous command separators, can lead to incorrect parsing. To mitigate this, the parser employs a state machine that tracks the context of each token, ensuring accurate interpretation. For example, when encountering a quote, the parser switches to a quote-parsing state, ignoring separators until the quote is closed. This mechanism reduces the risk of misinterpretation but adds overhead, which is acceptable given the priority of fidelity over performance in this context.

Emulating DOS Environment Variables and Batch File Processing

DOS’s environment variables, such as %PATH%, and batch file processing (.BAT files) required custom emulation in Go. Go lacks direct equivalents for these DOS-specific features, necessitating a custom implementation. The solution involved creating a virtual environment that mimics DOS’s variable expansion and batch file execution semantics.

For batch files, the reimplementation reads and processes lines sequentially, handling line endings and encoding differences between DOS and Unix-like systems. A common failure point is encoding mismatches, where non-ASCII characters in batch files may not render correctly. To address this, the system detects the encoding (e.g., CP437 for DOS) and converts it to UTF-8, ensuring compatibility with modern systems. However, this approach assumes the batch file’s encoding is known; otherwise, it defaults to UTF-8, risking misinterpretation of legacy files.

Handling DOS-Specific Features

Implementing DOS quirks like command chaining and redirection in a Unix-like environment required careful mapping. For example, DOS’s && operator executes the next command only if the previous one succeeds, which aligns with Unix’s && behavior. However, DOS’s & operator runs commands in parallel, a feature not natively supported in Unix shells. The reimplementation solves this by forking processes for parallel execution, leveraging Go’s concurrency primitives like goroutines.

While this approach works, it introduces a performance trade-off. Forking processes is resource-intensive, especially for large batch files. An alternative would be to simulate parallelism using time-slicing, but this would deviate from DOS’s behavior. The chosen solution prioritizes fidelity, accepting the performance cost as a necessary trade-off.

Cross-Platform Compatibility Layer

Ensuring compatibility across Unix-like systems involved abstracting system calls and file path handling. For instance, DOS uses backslashes () as path separators, while Unix uses forward slashes (/). The reimplementation translates paths dynamically, ensuring commands like *`DIR C:* work seamlessly on Linux or macOS.

A critical challenge was case sensitivity. DOS is case-insensitive, whereas Unix file systems are typically case-sensitive. The solution was to normalize paths to lowercase during processing, ensuring consistency. However, this approach fails if the host system has files with identical names differing only in case (e.g., file.txt and File.txt). In such cases, the reimplementation prioritizes the first match, a pragmatic but imperfect solution.

Error Handling and Debugging

Robust error handling was essential to replicate DOS’s forgiving nature with invalid commands. For example, DOS often returns cryptic but consistent error messages like Bad command or file name. The reimplementation mimics this behavior by catching unsupported commands and returning DOS-style error messages.

Debugging was complicated by the need to balance historical accuracy with modern usability. For instance, DOS’s single-threaded behavior could lead to deadlocks in complex command pipelines. While Go’s concurrency could optimize this, it would deviate from the original behavior. The decision was to maintain single-threaded execution, accepting potential performance bottlenecks as a trade-off for fidelity.

Decision Dominance: Fidelity vs. Modernization

Throughout the reimplementation, the choice between fidelity and modernization was central. For example, modernizing error messages or adding concurrency would improve usability but sacrifice historical accuracy. The optimal solution was to prioritize fidelity, ensuring the reimplementation behaves as closely as possible to the original COMMAND.COM.

Rule for Choosing a Solution: If the goal is to preserve historical accuracy and educational value, prioritize fidelity. If performance or usability is paramount, modernize selectively, but document deviations from the original behavior.

This approach ensures the reimplementation serves both nostalgic and practical purposes, bridging the gap between legacy DOS and modern Unix-like platforms while maintaining the essence of COMMAND.COM.

Implementation Scenarios

Reimplementing COMMAND.COM in Go for Unix-like platforms involves tackling six critical scenarios, each highlighting the interplay between legacy DOS functionality and modern system requirements. These scenarios are grounded in the system mechanisms, environment constraints, and typical failures identified during the reimplementation process.

1. Batch File Processing: Emulating DOS Execution Semantics

Batch file processing in DOS relies on sequential execution with implicit error handling and variable expansion. The Go implementation uses a custom virtual environment to mimic this behavior. For instance, when executing a .BAT file, the system reads the file line by line, detects encoding (e.g., CP437), and converts it to UTF-8 for compatibility. However, this approach assumes known encoding, risking misinterpretation of legacy files. Edge case: A batch file with mixed line endings (CRLF and LF) can cause the parser to skip lines, leading to incomplete execution.

2. Command Parsing: Handling Complex Syntax and Chaining

The COMMAND.COM parser is context-aware, handling command chaining (&, &&, ||), redirection (>, <), and pipes (|). In Go, this is achieved using a state machine that tracks token context. For example, nested quotes are resolved by maintaining a stack of quote states. Failure mechanism: Improperly nested quotes (e.g., "hello 'world'") can cause the parser to enter an infinite loop if the state machine lacks proper error handling.

3. System Call Translation: Mapping DOS Commands to Unix Equivalents

DOS commands like DIR and COPY have no direct Unix equivalents. The reimplementation uses a cross-platform compatibility layer to map these commands. For instance, DIR is translated to ls with options to mimic DOS output formatting. Risk: Commands relying on Windows-specific APIs (e.g., MODE COM1:) fail because their functionality cannot be replicated on Unix-like systems.

4. Environment Variable Expansion: Replicating %PATH% Behavior

DOS environment variables like %PATH% are expanded during command execution. The Go implementation uses a custom emulation layer to replicate this. For example, %PATH% is expanded by searching a predefined list of directories. Edge case: If a variable contains special characters (e.g., %PATH%;C:temp), the parser may misinterpret the semicolon as a command delimiter, leading to incorrect expansion.

5. Error Handling: Mimicking DOS Error Messages

DOS error messages are cryptic but consistent. The Go implementation catches unsupported commands and generates equivalent messages (e.g., Bad command or file name). This is achieved by maintaining a single-threaded execution model to preserve historical accuracy. Trade-off: Single-threaded execution risks deadlocks in complex command pipelines but ensures fidelity to DOS behavior.

6. Path Handling: Resolving DOS and Unix Path Differences

DOS uses backslashes () and case-insensitive paths, while Unix uses forward slashes (/) and case-sensitive paths. The reimplementation normalizes paths by converting backslashes to forward slashes and handling case insensitivity. Failure mechanism: In case-sensitive host systems, normalizing paths to lowercase can lead to conflicts if multiple files differ only by case (e.g., FILE.TXT and file.txt).

Decision Dominance: Fidelity vs. Modernization

The optimal solution prioritizes fidelity for historical accuracy while selectively modernizing for usability. For example, single-threaded execution is retained to preserve DOS behavior, despite performance trade-offs. Rule: If historical accuracy is critical (e.g., educational use), prioritize fidelity; if performance is paramount, modernize selectively.

This approach ensures the reimplementation serves both nostalgic and practical purposes, bridging the gap between legacy DOS and modern Unix-like platforms.

Challenges and Solutions

Reimplementing COMMAND.COM in Go for Unix-like platforms is no small feat. The process unearthed a series of technical hurdles, each demanding innovative solutions. Below, we dissect the major challenges and the mechanisms behind their resolution, grounded in the analytical model of the project.

1. Parsing and Executing DOS Commands: The Context-Aware Tokenizer

The COMMAND.COM parser is not just a tokenizer—it’s a context-aware system that handles command chaining (&, &&, ||), redirection (>, <), and pipes (|). Go’s strings and bufio packages were instrumental, but the real challenge was preserving DOS syntax nuances like nested quotes and special characters.

Mechanism: A state machine tracks token context, resolving nested quotes via a stack of quote states. For example, "hello 'world'" is parsed correctly by maintaining the state of open and close quotes. Failure Point: Improperly nested quotes (e.g., "hello 'world) can cause infinite loops without robust error handling. Solution: Implement a timeout mechanism to detect and terminate such loops, prioritizing fidelity over performance.

2. Emulating DOS Environment Variables: The Custom Virtual Environment

Go lacks direct equivalents for DOS-specific functionalities like %PATH% expansion. A custom virtual environment was built to mimic this behavior, including batch file processing (.BAT files).

Mechanism: Batch files are read line by line, with encoding detection (e.g., CP437) and conversion to UTF-8. Edge Case: Mixed line endings (CRLF and LF) can cause the parser to skip lines, leading to incomplete execution. Solution: Normalize line endings during parsing, ensuring compatibility across systems. Trade-off: This adds overhead but is necessary for historical accuracy.

3. Handling DOS-Specific Features: Command Chaining and Redirection

DOS command chaining and redirection had to be mapped to Unix equivalents. For instance, DIR is translated to ls, with output formatting mimicked using Unix command options.

Mechanism: Go’s goroutines were used to fork processes for parallel execution (& operator), accepting a performance trade-off for fidelity. Risk: Commands relying on Windows-specific APIs (e.g., MODE COM1:) cannot be replicated. Decision Rule: If a command relies on Windows-specific APIs → exclude it from the reimplementation, documenting the limitation.

4. Cross-Platform Compatibility: The Abstraction Layer

DOS path separators () and case insensitivity had to be mapped to Unix conventions. A compatibility layer dynamically translates paths and normalizes them to lowercase.

Mechanism: Paths are converted from to /, and case insensitivity is handled by prioritizing the first match in case conflicts. Failure Mechanism: On case-sensitive systems, normalizing to lowercase can cause conflicts (e.g., FILE.TXT vs. file.txt). Optimal Solution: Maintain a case-insensitive lookup table, ensuring correctness without altering the host system’s behavior.

5. Error Handling: Mimicking DOS Behavior

DOS’s forgiving nature with invalid commands required generating cryptic but consistent error messages. A single-threaded execution model was maintained to preserve historical accuracy.

Mechanism: Unsupported commands trigger DOS-style error messages (e.g., Bad command or file name). Trade-off: This risks deadlocks in complex pipelines but ensures fidelity. Rule: If performance is critical → modernize selectively, but prioritize fidelity for educational value.

Expert Observations and Analytical Angles

  • Historical Accuracy vs. Modern Usability: The reimplementation prioritizes fidelity, accepting performance trade-offs. For example, single-threaded execution preserves DOS behavior but limits concurrency.
  • Security Implications: The interpreter prevents malicious use by sandboxing external commands, ensuring they cannot access sensitive system resources.
  • Educational Value: The project can be extended to include interactive tutorials, visualizing DOS command execution step by step.
  • Community Engagement: Open-source distribution fosters collaboration, with retrocomputing enthusiasts contributing to edge-case handling and feature enhancements.

In conclusion, reimplementing COMMAND.COM in Go is a testament to the language’s versatility and the enduring relevance of legacy systems. By balancing fidelity and modernization, the project bridges the gap between historical computing and modern development, ensuring DOS’s legacy remains accessible and meaningful.

Build a Voice-Enabled AI Agent in n8n

Originally published at kjetilfuras.com

When I started playing with AI Agents inside n8n, I wanted to go beyond just asking questions. I wanted a real assistant—something I could talk to (literally), that could respond, take action, and help automate everyday tasks like sending emails or booking meetings.

The result was a self-hosted AI Agent that works entirely through Telegram. It understands both text and voice messages, and responds in kind. It’s also integrated with Gmail, Google Calendar, and Airtable.

You can read up on Airtable here.

This article walks you through how I built it—from trigger to tools to TTS response.

What It Does

Here’s what my AI Agent can do:

  • Accept text or voice input via Telegram.

  • Transcribe voice using OpenAI Whisper.

  • Use GPT-4o-mini via the AI Agent node to reason over the prompt.

  • Automatically use tools like Gmail, Google Calendar, or Airtable.

  • Reply either as a message or an audio file using Kokoro TTS.

Overview of the Stack

  • n8n (self-hosted)

  • AI Agent (n8n’s integration)

  • GPT-4o-mini via OpenAI

  • Airtable for contact lookup

  • Gmail and Google Calendar

  • Kokoro TTS (self-hosted)

  • Telegram Bot as frontend

1. Telegram as the Frontend

The workflow starts with a Telegram Trigger node. It picks up both text messages and voice recordings.

2. Handling Voice Input

If the message is a voice file, I use Telegram’s file API to download it and pass it to OpenAI Whisper (via the Transcribe Audio node). This gives us a clean text version of what the user said.

3. Configuring the AI Agent

The AI Agent node connects GPT-4o-mini with a memory buffer and system instructions. It uses the user’s Telegram chat ID as a custom session key to keep conversations contextual.

My system prompt includes tool usage guidance and a fallback if no user or email is provided:

You are a helpful assistant.
Always use the “Get Contacts” tool for finding an email address for the “Send Email” and “Book Event” tool. If I fail to provide a User or Email you default to (myself) “My Name” at Email “MyEmailAddress”

4. Tools the Agent Can Use

Tools are defined using AI Agent’s-compatible nodes. In my case, the agent can:

  • Look up contacts in Airtable

  • Send email via Gmail

  • Book events via Google Calendar

  • Call another n8n workflow to fetch API endpoints

Each tool connects to the AI Agent through the Tool input port, like this:

5. Responding Back to the User

After generating the output, the workflow branches based on whether the input was text or voice:

  • If text → reply using Telegram text node.

  • If voice → send the output to Kokoro TTS to generate an .mp3 voice message, and send that back.

The Kokoro HTTP node uses a POST request with payload like:

{
  "model": "kokoro",
  "input": "{{ $json.output }}",
  "voice": "am_adam",
  "response_format": "mp3",
  "download_format": "mp3",
  "return_timestamps": false,
  "speed": 1
}

This gives you natural-sounding audio replies directly inside Telegram.

Real Examples

Here are a few real prompts I’ve tested:

  • “Send an email to Alex confirming our call tomorrow.” → Agent finds Alex’s email in Airtable and sends the message via Gmail.

  • “Book a meeting with Jane next Tuesday at 2pm.” → Adds an event to my Google Calendar.

  • Voice: “What’s my next meeting?” → Transcribed and answered with a voice reply.

Final Thoughts

This setup gives me a real assistant that lives inside Telegram. It listens to me, thinks, takes action, and speaks back. I use it daily—and I’m just getting started.

You could easily extend this with more tools: document summarization, Notion notes, file handling, or webhook integrations.

For companies, this kind of setup can also solve real operational problems:

  • Auto-schedule meetings with leads using natural language (e.g., “Book a demo with Sam next Thursday”)

  • Look up internal contacts or CRM data and send contextual emails

  • Summarize meeting transcripts and send key takeaways to Slack or Teams

  • Trigger IT workflows or incident responses via chat

  • Answer internal questions from private documentation in Notion, Confluence, or Google Drive

  • Generate quick reports from Airtable, dashboards, or APIs

If you want to try it out yourself, I’d recommend starting with the voice + text input logic and AI Agent node—then layer on tools as you go.

Want the full workflow JSON or a walkthrough? DM me on LinkedIn or X — happy to share!

I’ll be sharing more n8n automation workflows here on the blog soon — stay tuned!

Automate WordPress Blog Publishing with Airtable and n8n

Image

Originally published at kjetilfuras.com

I wanted to automate WordPress blog publishing because the manual process was killing my productivity. Between writing content, make featured images, uploading to WordPress, configuring SEO settings, and double-checking everything looks right—publishing a single post used to take me 15-20 minutes of clicking through different interfaces.

Now I do it in 30 seconds with 5 manual actions.

Here’s how I built this system using Airtable as my CMS, n8n as the automation engine, and WordPress as the final destination.

Quick Update

After running this automation system for a while, I realized the workflow was solving a much bigger problem: publishing from Notion to WordPress automatically.

I eventually turned the automation into a small tool called Notipo. It removes the need to maintain the full Airtable + n8n automation stack and lets you publish your Notion content directly to WordPress.

👉 https://notipo.com

The Problem with Traditional Publishing

Before automation, my workflow looked like this:

  • Write content in my editor of choice (usually Markdown)
  • Open Canva or similar tool to design a featured image
  • Download and manually optimize the image
  • Log into WordPress
  • Create new post, paste content, manually change all WordPress built-in code blocks over to Prismatic and select the correct code language
  • Upload featured image, set it as featured
  • Configure Rank Math SEO settings (title, description, focus keyword)
  • Preview, adjust formatting
  • Hit publish

That’s 9 separate steps across multiple tools. Context switching kills productivity, and I found myself procrastinating on publishing because the overhead just wasn’t worth it.

The Solution: A Three-Step Publishing System

Now my workflow is:

Step 1: Write in Notion (4 actions)

  • Add blog title
  • Select category, Featured Image Title, SEO Keyword
  • Write content in markdown
  • Change status to “Ready to Publish” → Automation syncs to Airtable → Airtable automation generates featured image

Step 2: Create Draft (1 action)

  • In Airtable, change Status to “Post to WordPress” → Automation creates WordPress draft with featured image and SEO configured (~15-20 seconds)

Step 3: Review & Publish (1 action)

  • Review draft in WordPress admin, then change Airtable Status to “Publish” → Automation publishes the post live

Total: 6 manual actions, ~30 seconds of automation time.

Everything else—image generation, WordPress draft creation, featured image upload, SEO configuration—happens automatically. I still review the draft in WordPress before publishing, then change the status in Airtable to trigger the final publish.

It handles multiple inline images too. Drop images in your Notion, and they all get uploaded to WordPress and properly embedded. No manual intervention needed.

System Architecture

Here’s how the pieces fit together:

Data Layer: Airtable Base

The foundation is an Airtable base with 5 interconnected tables:

  • Variables Table holds all configuration (webhook URLs, API endpoints, production toggle)
  • Featured Image Table triggers image generation via n8n webhook
  • Posts Table triggers WordPress publishing via n8n webhook
  • Category Table stores WordPress IDs, tags, and background images
  • Image Mappings Table caches Notion→WordPress image URLs (this is the secret sauce for fast re-syncs)

Automation Layer: n8n Workflows

Two separate workflows handle different tasks:

  • Notion to Airtable Sync (51 nodes): Triggers on Notion changes → converts to Markdown → routes to F1 (no images) or F2 (with images) → caches image mappings → syncs to Airtable
  • Publish to WordPress (23 nodes): Featured image generation + WordPress publishing with 3-path routing (publish, edit, create)

Publishing Layer: WordPress

The final destination receives:

  • Published posts with Gutenberg blocks
  • Featured images properly attached
  • All inline images uploaded and embedded
  • Rank Math SEO metadata configured

Breaking Down the Components

Image

1. Airtable as My CMS

I use Airtable as my content management system because it’s flexible, visual, and has powerful automation capabilities. My base has 5 tables:

Featured Image Table

  • Title (Single line text)
  • Category (Link to Category table)
  • Processed Image (Attachment field – populated by automation)

Category Table

  • Name, WordPress ID, Tags
  • Background Image URLs (for featured image generation)

Posts Table

  • Title (Single line text)
  • Content (Long text – Markdown format from Notion)
  • Featured Image (Link to Featured Image table)
  • Category (Link to Category table)
  • Status (Single select – “Draft”, “Synced to Airtable”, “Post to WordPress”, “Update Pending”, “Posted to WordPress”)
  • WordPress ID (auto-populated)

Variables Table

  • Name, Value pairs for all configuration
  • Examples: “Generate Featured Image”, “Post to WordPress”, “NCA Endpoint”, “WordPress Media Url”, “WordPress Posts Url”
  • Production mode toggle (strips “-test” from all URLs)

Image Mappings Table

  • Notion Image URL → WordPress Image URL mapping
  • WordPress Media ID (for tracking)
  • Linked to Posts table
  • This table is the performance secret—subsequent syncs reuse cached WordPress URLs instead of re-uploading images

The Variables table is brilliant because I can switch between test and production environments by changing a single checkbox. All webhook URLs and automatically adjust.

2. Airtable Automations (5 Total)

Image

Airtable automations connect the UI to n8n. I have 5 automations split into two categories:

n8n Automations (3) – Scripts that trigger n8n webhooks:

  • Process Image: Record enters “Ready for Processing” view → calls n8n to generate featured image
  • Send to WordPress: Record enters “Send to WordPress” view → calls n8n to create new post
  • Publish Post: Record enters view → calls n8n to publish existing draft

Airtable Automations (2) – Native Airtable actions:

  • Import Processed Image: Receives n8n webhook callback → updates Featured Image with generated URL
  • Create New Post: Record enters “Create New Post” view → creates linked Post record

Here’s a simplified version of the automation script:

// 1. Fetch configuration from Variables table
let table = base.getTable('Variables');
let query = await table.selectRecordsAsync();
let variables = {};

for (let rec of query.records) {
  let name = rec.getCellValue('Name');
  let value = rec.getCellValue('Value');
  if (name && value) {
    variables[name] = value;
  }
}

// 2. Get webhook URL based on trigger type
let params = input.config();
let webhookName = params.webhook || 'Generate Featured Image';
let webhookUrl = variables[webhookName];

// 3. Send data to n8n
await fetch(webhookUrl, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    recordId: params.recordId,
    ...variables
  })
});

This script does three things:

  • Fetches configuration from the Variables table
  • Selects the correct webhook based on what triggered the automation
  • Sends everything to n8n for processing

The production version I actually use is more sophisticated—it normalizes variable names to camelCase, handles environment switching between test and production with a single toggle, groups related config by prefix, and does dynamic payload building. But this simplified version shows the core concept.

Want the complete implementation? I’m sharing the production-ready automation scripts, complete n8n workflow JSON, Airtable base template, and setup guides in my Build-Automate community. The community is $24/month, and you can access this project and others like it.

3. n8n Workflows:

I have two separate n8n workflows that handle different parts of the system.

Workflow 1: Notion to Airtable Sync (51 nodes)

Image

This is the big one. When you change a Notion page status to “Ready to Publish”:

The Smart Part: F1/F2 Flow Routing

Image

The workflow detects whether your post has inline images and routes accordingly:

  • F1 (No Images): Streamlined sync—just converts markdown and creates/updates the Airtable record

Image

  • F2 (With Images): Full image processing pipeline with caching

Image

Why does this matter? Text-only posts sync in seconds. Posts with 10 images don’t slow down posts with zero images.

Image Mapping Cache (The Performance Secret)

Image

Here’s what made this system actually usable for updates:

  • First sync: Upload image to WordPress, store Notion URL → WordPress URL mapping in Airtable
  • Second sync: Check cache first. Image already uploaded? Reuse the WordPress URL. Skip the upload entirely.
  • Remove an image from your post? Orphan cleanup automatically deletes the unused mapping.

Before I built the cache, updating a post with 5 images meant re-uploading all 5 images every time. Now it takes seconds because it just swaps in the cached URLs.

Workflow 2: Publish to WordPress (23 nodes)

This workflow handles two pipelines:

Pipeline 1: Featured Image Generation

Image

  • Airtable automation triggers n8n webhook with record ID
  • n8n fetches category background image from Airtable
  • NCA API (Python/PIL) generates 1200×628 image with title overlay
  • Image uploads to S3
  • Callback updates Airtable with the image URL

Total time: ~10 seconds

Pipeline 2: WordPress Publishing (3-Path Routing)

Image

This is the magic. The workflow uses a Switch node to route to three different paths:

  • n8n fetches the complete record (content, featured image, category, tags)
  • Markdown converter transforms content into Gutenberg blocks & Prismatic blocks for code (Prism.js)
  • Switch routes based on what action is needed:

The Create path does the heavy lifting:

  • Creates WordPress draft
  • Downloads and uploads featured image
  • Sets alt text, title, caption on media
  • Attaches featured image to post
  • Auto-configures Rank Math SEO (focus keyword, description, title)
  • All via WordPress REST API—no manual SEO screen clicking

The 3-path routing was a game-changer. Before, I had one path trying to handle everything. Now each action has its own clean flow. Publishing a draft? Path 1. Updating existing content? Path 2. Brand new post? Path 3.

The Rank Math automation is underrated. Every post gets a 66/100 SEO score out of the box without touching the SEO panel. Not perfect, but way better than forgetting to set it entirely.

Total time: ~15-20 seconds

4. WordPress Configuration

For this to work, WordPress needs a few things configured:

Required Plugins:

  • Rank Math SEO (for SEO meta fields)
  • Prismatic (for syntax highlighting in code blocks)

Application Password:
WordPress → Users → Your Profile → Application Passwords
Create a new password for n8n API access.

Rank Math REST API Registration (functions.php):

Image

/**
 * Register Rank Math SEO meta fields for WordPress REST API
 */
add_action('init', function () {
  $keys = [
    'rank_math_title',
    'rank_math_description',
    'rank_math_focus_keyword',
  ];

  foreach ($keys as $key) {
    register_post_meta('post', $key, [
      'type'          => 'string',
      'single'        => true,
      'show_in_rest'  => true,
      'auth_callback' => function () {
        return current_user_can('edit_posts');
      },
    ]);
  }
});

This makes Rank Math fields accessible via REST API, allowing n8n to set SEO metadata programmatically.

The Publishing Experience

Now when I want to publish a blog post:

  • Write in Notion:
  • Create Draft:
  • Review & Publish:

Updating a post? Even easier:

  • Edit in Notion, change status to “Ready to Publish” again
  • Sync detects existing post, updates Airtable (status becomes “Update Pending”)
  • Change to “Post to WordPress”—it updates the existing WordPress post instead of creating a duplicate

No context switching. No manual uploads. No image creation. No SEO configuration screens.

Why This Works Better Than Other Solutions

vs. WordPress Native:

  • Markdown is faster than Gutenberg editor
  • Built-in markdown preview links (copy content → paste in markdownlivepreview.com)
  • Posts created as drafts – review in WordPress admin before publishing

vs. Static Site Generators:

  • No git commits or deployment pipelines
  • Non-technical users can publish (if needed)
  • WordPress ecosystem and plugins still available

vs. Zapier/Make:

  • n8n is self-hosted (no monthly limits)
  • More control over data and logic
  • Free for unlimited workflows

vs. Manual Image Creation:

  • Consistent branding across all images
  • 10 seconds vs. 5-10 minutes per image
  • No design skills required

vs. Re-uploading Images Every Time:

  • Image mapping cache means updates are fast
  • Edit a post with 10 images? Cache hits, no re-uploads
  • Remove an image? Orphan cleanup keeps things tidy

Environment Management with Variables Table

The Variables table approach deserves special attention because it solves a common problem: testing workflow changes without affecting production.

The same workflow handles both test and production through different webhook URLs:

In my Variables table, I store the test URLs by default:

When Production is true, the Airtable automation script automatically strips -test from all webhook URLs. Every automation instantly switches to production endpoints.

This means:

  • One automation script handles both environments
  • Zero risk of accidentally publishing to production during testing

Performance & Reliability

The system handles posts reliably with consistent timing:

  • Featured image generation: 8-12 seconds (depends on Python API)
  • WordPress publishing: 15-20 seconds (network + API calls)
  • Airtable updates: <1 second

Total automation time: ~30 seconds from changing the status to WordPress draft ready.

Getting Started with This System

If you want to build something similar:

  • Start with Airtable
  • Set up n8n
  • Configure WordPress
  • Connect the pieces

From Workflow to Product

While this automation works great for my own blog, maintaining Notion, Airtable, n8n, and multiple integrations still requires a fair amount of setup.

After running this system for a while, I started turning the workflow into a dedicated tool called Notipo.

Notipo handles the entire publishing flow — from Notion content to WordPress posts with featured images, inline image uploads, and SEO metadata — without needing to maintain the automation stack.

If you’re interested, you can check it out here:

👉 https://notipo.com

Related Posts

Final Thoughts

I test every code snippet and workflow step before publishing. Writing about automation means the tutorials have to actually work.

Why Docker Compose and .env Can Break NFS Bind Mounts

Originally published at kjetilfuras.com

Recently while setting up a Docker Compose-based media stack, I ran into a tricky issue. It looked like everything was working, the containers were up, the mounts were in place, and the files were visible, yet I couldn’t write to my NFS share from inside the containers.

Here’s what happened, and how I fixed it.

What I Was Building

I had a shared NFS mount point at /mnt/data on my Ubuntu Docker host, and I wanted to use environment variables to keep things clean:

In my .env:

# Data Share
DATA=/mnt/data

And in my docker-compose.yml:

volumes:
  - ${DATA}:/data

This approach seemed solid — clean, reusable, and friendly for future automation.

What Looked Right

  • The container started successfully

  • The NFS share mounted properly on the host

  • Inside the container, I could ls /data and see all my files and folders

  • Permissions looked fine on the host: the NFS export mapped to UID/GID 1000, which matched the container user

Where It Broke

Despite being able to browse files, I couldn’t write to the mounted folder inside the container.

Commands like:

touch /data/testfile

would fail with:

Permission denied

What I Discovered

After hours of chasing NFS export settings, UID mapping, and file ownership, I tried one small change that fixed it instantly:

volumes:
  - /mnt/data:/data

Just like that, everything worked — I could read, write, and delete files inside /data from within the container.

The Real Issue

While using environment variables like ${DATA} in volume paths is supported by Docker Compose, they don’t always behave reliably with bind mounts, especially when the host path involves mounted filesystems like NFS.

Even though ${DATA} correctly pointed to /mnt/data

  • Docker Compose resolved the variable

  • Mounted the NFS share correctly

  • But bind permissions didn’t behave as expected when the path came via an environment variable

It’s unclear whether there was a problem with Docker Compose, the NFS client, or how the filesystem is resolved. But using an env variable in the volume path led to silent permission issues, even though everything else appeared fine.

What I Recommend

Use an absolute path for bind mounts

volumes:
  - /mnt/data:/data

It’s simple, reliable, and eliminates variable resolution from the bind mount path.

Final Thoughts

If your container can read but not write to an NFS-mounted volume using an environment variable, try an absolute path instead. It might just save you hours of debugging.

Found this helpful? I share more hands-on fixes and Docker tips from my homelab over on GitHub and here on the blog.

Adding Bluesky Rich Link Preview cards to Postiz

Image

Originally published at kjetilfuras.com

Rich link preview cards make a real difference on Bluesky—the clickable previews that show a title, description, and image when you share a URL. Same thing Twitter does with Open Graph cards. They get more engagement than plain text links.

The problem? Postiz doesn’t support this yet for Bluesky.

Hillary Torn has a pull request that adds exactly this feature, but it hasn’t been merged. Rather than wait, I built a custom Docker image with her changes.

Here’s how I did it, and how you can use the same image.

Image

What This Enables

When you post a blog link to Bluesky through Postiz, viewers see a styled card with:

  • Post title from your blog’s OG tags
  • Description pulled from meta tags
  • Featured image
  • Clickable link to your post

Without this, you just get a plain text URL. The difference in engagement is significant—visual cards get more clicks.

The Build Process

Step 1: Fork and Clone

I forked the Postiz repo so I could apply Hillary’s changes:

git clone https://github.com/kfuras/postiz-app.git
cd postiz-app
git checkout -b feature/bluesky-link-previews

Step 2: Merge the PR

I pulled her changes directly:

git fetch upstream pull/1078/head
git merge FETCH_HEAD

One merge conflict in the Bluesky provider file—I resolved it by keeping her version:

git checkout --theirs libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
git add libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts
git commit -m "Merge bluesky embed changes for link previews"

Step 3: Build for Linux

I develop on Mac but deploy to a Linux VPS, so the platform flag is important:

docker build --platform linux/amd64 
  -t ghcr.io/kfuras/postiz-app:bluesky-link-previews 
  -f Dockerfile.dev .

The –platform linux/amd64 ensures the image works on Linux servers even though I’m building on Apple Silicon.

Step 4: Push to GHCR

echo $GHCR_PAT | docker login ghcr.io -u kfuras --password-stdin
docker push ghcr.io/kfuras/postiz-app:bluesky-link-previews

I store my GitHub PAT as GHCR_PAT in my .zshrc. Using –password-stdin keeps the token out of shell history.

I made the package public and linked it to my forked repo, so you can pull it without authentication.

Using the Image

Update your docker-compose.yml:

postiz:
  image: ghcr.io/kfuras/postiz-app:bluesky-link-previews
  # ... rest of your config

Then pull and restart:

docker compose pull
docker compose down
docker compose up -d

Testing the Image

After deploying the custom image, I tested it both through the Postiz web app and via n8n’s Postiz node. The link preview cards generate correctly in both cases—Hillary’s code handles the OG tag extraction automatically when you include a URL in your post.

When the Official Version Ships

This is a temporary workaround. Once Hillary’s PR gets merged into the official Postiz release:

  • Switch back to the official image: ghcr.io/gitroomhq/postiz-app:latest
  • Remove the custom build
  • The feature works the same way

You can track the PR status here: github.com/gitroomhq/postiz-app/pull/1078

Why This Works

vs. Waiting for the merge:

  • You get the feature now instead of waiting weeks or months
  • Zero code changes needed—just swap the Docker image

vs. Building from scratch:

  • Hillary already did the implementation work
  • Fork + merge + build takes about 10 minutes

vs. Manual link cards:

  • Automatic OG tag extraction via n8n
  • No manual copy-paste for every post

Resources

Credit: The Bluesky link preview implementation is entirely Hillary Torn’s work. This post documents building a Docker image with her unmerged changes. If you find this useful, consider leaving a comment on her PR.

Related Posts

GTA 6 ertelendi mi? Paylaşımlar sonrası GTA 6 yine mi ertelendi? İşte çıkış tarihi

GTA 6 ertelendi mi? Sosyal medyada hızla yayılan “GTA 6 yeniden ertelendi” paylaşımları, 1 Nisan günü yapılan mizah içerikleriyle gündeme geldi. Oyunun çıkış sürecine ilişkin daha önce yapılan resmi açıklamalar ve duyurular, belirlenen takvimin nasıl şekillendiğini ortaya koymuşken, son paylaşımlar oyuncular arasında kafa karışıklığı yarattı.