cd /news/developer-tools/show-hn-kaupang-a-push-based-deploy-… · home topics developer-tools article
[ARTICLE · art-31024] src=github.com ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Show HN: Kaupang – a push-based deploy CLI, now with a drag-and-drop builder

Kaupang, a push-based deploy CLI for Docker Compose, Swarm, and Kubernetes, has been released with a drag-and-drop builder. The tool allows developers to define environments in TypeScript and deploy the same configuration locally and in CI, with immutable, auditable deploys and rollback support. It targets software teams seeking a simple, imperative deployment workflow without a control loop.

read21 min views1 publishedJun 17, 2026

kaupang(KOW-pang)— Old Norse for a Viking-agetrading hub, fromkaup"trade" +vangr"field, place." A kaupang was where goods from every craft were gathered, packed, and loaded onto ships bound for distant shores. This tool is that hub for your software: it gathers your services, pins them into a sealed, portable cargo, and dispatches them anywhere — your laptop, a CI agent, a remote swarm, or an airgapped site across the water. 🛶

Spin up environments on Docker Compose, Docker Swarm, or Kubernetes from a single TypeScript config — the same command on your laptop and in CI.

kaupang is an imperative, push-based deploy tool — an environment maker, not a reconciler. You run it, it makes a target match your config, it records exactly what it did, and you can replay or roll back. No control loop, no cluster agent.

kaupang up api --target staging      # build the plan, resolve images, deploy
kaupang up api --dry-run             # show the dependency graph + commands, run nothing
kaupang rollback api --target prod   # re-apply the previous good deployment
kaupang down api                     # tear it back down

One tool, laptop to pipeline.kaupang up api --target staging

is byte-for-byte the same locally and on an agent. Only thetarget(context, env, backend) differs.Config is code. Environments are TypeScript, so image tags and env values can come straight fromprocess.env

/ pipeline variables — no templating language.Immutable, auditable deploys. Every deploy resolves image tags to a digest, pins it, and records the whole artifact in a ledger.rollback

replays a known-good snapshot.Promotion is trivial. Staging validates a digest; prod redeploys that exact digest. "What I tested is what I ship."Multi-backend. Swarm staging and Kubernetes prod from the same environment definition.

Quick startConfigEnvironments & servicesCatalog (shared presets)Targets (staging vs prod)Solutions & bundlesPipelinesSingle-service repoVersioning: resolve, pin, record, roll backSecretsPull behaviorBuild & hooksDependency resolutionBackendsUsing kaupang in Azure DevOpsCommand referenceDevelopRoadmap

npm install -g @kaupang/cli        # or use via npx in CI

Create a kaupang.config.ts

at your repo root and an environments/

folder:

// kaupang.config.ts
import { defineConfig } from "@kaupang/core";

export default defineConfig({
  environments: "./environments",
  project: "longhall",
  defaultBackend: "compose",
});
js
// environments/api.ts
import { defineEnvironment } from "@kaupang/core";

export default defineEnvironment({
  services: {
    web: { image: "ghcr.io/midgard/longhall-api:latest", ports: ["8080:3000"] },
  },
});
kaupang up api            # deploys locally with docker compose
kaupang up api --dry-run  # preview the plan first
js
import { defineConfig } from "@kaupang/core";

export default defineConfig({
  environments: "./environments",     // folder of environment files
  project: "longhall",                    // namespaces stacks / projects / namespaces
  defaultBackend: "compose",          // compose | swarm | kubernetes
  dockerRepository: "ghcr.io/midgard",   // prefix applied to bare image names
  defaultPull: "missing",             // always | missing | never
  cacheDir: ".kaupang",               // where generated files + the ledger live
  globalEnv: { TZ: "UTC" },           // injected into every service everywhere
  catalog: { sources: [{ type: "file", path: "./catalog.json" }] },
  targets: { /* see Targets */ },
});

Env var precedence (low → high): globalEnv

< environment env

< target env

< service env

.

Not a TypeScript repo?You don't need one. kaupang loads its config fromkaupang.config.ts

,.mjs

,.js

,or(and.json

environments/*

in any of those) — notsconfig

, notypescript

dependency, no build step. Pick what fits:

— full type-checking + autocomplete (the extension is transpiled at runtime)..ts

— plain JavaScript objects, with comments and the.js

/.mjs

secret()

/use()

helpers. Add/** @type {import('kaupang').KaupangConfig} */

for checking.— pure data, ideal for machine-generated configs. The helpers become literal tags:.json

secret("X")

{ "$secret": "X" }

,use("postgres")

{ "$catalog": "postgres" }

. (No comments, and you write the tags by hand.)The

define*

helpers are identity functions, so a plain object — or a JSON file — is all they ever produce.

An environment is a docker-compose

-like definition, but in TypeScript. The ergonomic surface is deliberately small:

import { defineEnvironment, use } from "@kaupang/core";

export default defineEnvironment({
  dependsOn: "postgres",                  // string or string[] — other environments
  services: {
    db: use("postgres"),                  // a whole service from the catalog
    web: "longhall-api:latest",               // string shorthand → { image: "longhall-api:latest" }
    worker: { image: "longhall-api", dependsOn: "web" },
  },
});
  • A service can be a plain string(the image), a full** object**, or a catalog reference via.use()

dependsOn

accepts a string or an array, at both the environment and service level.- With dockerRepository

set,bare names (longhall-api

) get prefixed (ghcr.io/midgard/longhall-api

). Fully-qualified names (anything with a/

) and catalog presets are left alone, so public images stay public.

Because the file is TypeScript, dynamic values just work:

web: { image: `longhall-api:${process.env.LONGHALL_API_TAG ?? "latest"}` }

That single line is what lets a pipeline pin a build by setting one env var.

Reusable service definitions that live outside your repo and are pulled in at load time — so a brand-new environment is a few use()

calls plus your own service. A catalog is a manifest of named presets:

{
  "services": {
    "postgres": { "image": "postgres:16-alpine", "ports": ["5432:5432"],
                  "healthcheck": { "test": ["CMD-SHELL", "pg_isready"] } },
    "redis":    { "image": "redis:7-alpine", "ports": ["6379:6379"] }
  }
}

Reference presets with use("name", overrides?)

. Sources are pluggable (src/catalog/source.ts

implements CatalogSource

); earlier sources win:

source status use it for
file
shipped a manifest committed to a shared infra repo / vendored
http
shipped a static manifest served by an internal endpoint
service
shipped a running internal catalog service kaupang queries
oci
shipped a versioned catalog published as an OCI artifact (ORAS)

The service

source (a long-running internal catalog — e.g. a container your platform team owns) just needs to answer a GET with JSON in this shape; both keys are optional, so one service can serve presets and solutions together:

// GET https://catalog.internal/  →
{
  "services":  { "postgres": { "image": "postgres:16-alpine", "ports": ["5432:5432"] } },
  "solutions": { "hedeby": { "version": "2.3.1", "environments": ["api"],
                                 "pins": { "api.web": "longhall-api@sha256:…" } } }
}
catalog: { sources: [{ type: "service", url: "https://catalog.internal", headers: { Authorization: "Bearer …" } }] }

That single service is the "container that handles all things" — your shared service presets and the solution recipes teams compose on the fly.

If you'd rather version the catalog as an immutable artifact in a registry you already run (ACR, Harbor, ghcr, ECR …), publish it with ORAS and reference it by ref

. No new infrastructure — it reuses your image registry.

cat > catalog.json <<'JSON'
{ "services":  { "postgres": { "image": "postgres:16-alpine", "ports": ["5432:5432"] } },
  "solutions": { "hedeby": { "version": "2.3.1", "environments": ["api"] } } }
JSON

oras login midgardregistry.azurecr.io -u <user> -p <token>

oras push midgardregistry.azurecr.io/kaupang-catalog:1 catalog.json
catalog: {
  sources: [{ type: "oci", ref: "midgardregistry.azurecr.io/kaupang-catalog:1" }],
}

kaupang pulls it with oras pull

at load time, using your existing oras

/docker login

credentials — the same auth story as image resolution, so there's nothing extra to set up. By default it reads catalog.json

from the artifact; push a different filename and set it via the source's path

. Pin a tag (or a digest) for reproducibility, exactly like images.

A target is where kaupang deploys: a remote cluster, a remote host, or your machine. The same environment definition deploys to every target — only the target's backend, env, and context differ. No duplicated environment files to drift.

targets: {
  local: {},                                   // your default docker context
  staging: {
    backend: "swarm",
    dockerContext: "staging-swarm",            // → docker --context staging-swarm …
    pull: "always",                            // floating channel, roll forward
    env: { APP_ENV: "staging", PUBLIC_URL: "https://staging.longhall.example" },
  },
  prod: {
    backend: "kubernetes",
    kubeContext: "aks-prod",                   // → kubectl --context aks-prod …
    kubeconfig: "./kubeconfig-prod",           // sets KUBECONFIG for the run
    pull: "missing",                           // deploy the digest staging validated
    env: { APP_ENV: "production", PUBLIC_URL: "https://longhall.example" },
  },
},
kaupang up api --target staging   # docker --context staging-swarm stack deploy …
kaupang up api --target prod      # kubectl --context aks-prod apply …

Context selection rides on the tools' own mechanisms — docker --context

/ DOCKER_HOST

and kubectl --context

/ KUBECONFIG

. Authenticate in your pipeline as usual (az acr login

, az aks get-credentials

) and kaupang inherits it. An unknown --target

is a hard error that lists the configured ones. The ledger is scoped per environment@target

, so staging and prod keep separate histories and roll back independently.

A solution is a named, versioned composition of environments — a whole customized product for a customer or site. Recipes can live inline in config or, more usefully, in the hosted catalog (so they're composed on the fly without a repo):

solutions: {
  "hedeby": {
    version: "2.3.1",
    environments: ["birka", "ribe"], // dependencies pulled in automatically
    pins: { "birka.web": "longhall-api@sha256:…" }, // per-service version pins
    env: { JARL: "x" },
    target: "site-x",
  },
}

Deploy a solution directly (online — resolves images and deploys):

kaupang up --solution hedeby --target site-x

Or bundle it into a portable, fully-pinned artifact — the unit a pipeline produces, and what crosses an air gap:

kaupang bundle hedeby --target site-x --with-images -o ./hedeby.bundle

A bundle is self-contained and relocatable: it holds the generated artifacts and commands with bundle-relative paths, plus (with --with-images

) docker save

d image tarballs. Deploy it with no source repo and no registry:

kaupang up --bundle ./hedeby.bundle --target site-x

Or distribute the bundle through a registry — the bridge between the online and airgap flows. Push a materialized bundle as a single versioned OCI artifact, then pull-and-deploy it anywhere by ref:

kaupang bundle hedeby --target site-x --push oci://midgardregistry.azurecr.io/bundles/hedeby:2.3.1
kaupang up --bundle oci://midgardregistry.azurecr.io/bundles/hedeby:2.3.1 --target site-x

Push/pull use oras

(same auth as the OCI catalog source), so a solution becomes a versioned, pullable thing in the registry you already run. up --bundle

accepts either a local directory or an oci://

ref.

up --bundle

loads any included images and replays the pinned artifacts through the target's context — the same deploy path as everything else, just offline. It records to the ledger like any deploy, so rollback works inside the gap too (bounded by which versions were carried in).

The chain: service presets → solution recipe (catalog) → bundle (pipeline, pinned

  • images) → target → live URL. Online and airgapped use the samebundle artifact; only the transport differs.

A pipeline is an ordered set of steps — shell commands, deploys, builds, and waits — wired together as a DAG. It's the recipe part of CI: the portable sequence of kaupang actions that runs byte-for-byte the same on your laptop and on an agent. The CI system still owns triggers, approvals, and secrets; the pipeline owns what actually happens.

import { defineConfig, definePipeline } from "@kaupang/core";

export default defineConfig({
  // …environments, targets…
  pipelines: {
    release: definePipeline({
      steps: {
        build:  { run: "npm run build" },
        deploy: { up: "api", needs: "build" },
        smoke:  { wait: { http: "http://localhost:8080/health", timeout: "30s" }, needs: "deploy" },
        done:   { run: "echo released", needs: "smoke" },
      },
    }),
  },
});
kaupang run release --target staging
kaupang run release --dry-run     # print the step DAG, run nothing

Each step declares exactly one action and optional needs

:

action does
run: "<cmd>"
runs a shell command from the config root
up: "<env>"
deploys an environment — same path as kaupang up <env>
down: "<env>"
tears an environment down
build: "<env>"
builds the environment's images
wait: { … }
gates on http (poll a URL for a status) or seconds (sleep)

Steps are topologically sorted from needs

; cycles are rejected up front, and the dry-run shows which steps fall in the same wave (independent, so safe to parallelize later). up

/ down

/ build

steps honor --target

(or a per-step target

/ backend

override) and go through the identical resolve → pin → materialize → ledger path as the top-level commands — a pipeline up

is a real, recorded deploy, not a second code path. wait

example:

smoke: { wait: { http: "https://api.longhall.example/health", status: 200, interval: "2s", timeout: "2m" }, needs: "deploy" }

In Azure DevOps this is one step inside a stage — npx @kaupang/cli run release --target staging

— so approvals/gates stay in the YAML while the deploy recipe stays in kaupang and stays runnable locally.

Every up

resolves each service's image reference to an immutable digest at deploy time, pins that digest into the generated artifact, and records the whole deployment in a ledger (.kaupang/ledger.json

). One mechanism supports both intents — you choose by what you write:

Pinnedimage: "longhall-api@sha256:…"

oruse("longhall-api@1.4.2")

. A named version; re-runningup

won't roll forward unless you edit the file.Floatingimage: "longhall-api:latest"

, a channel like@stable

, oruse("longhall-api")

. May point somewhere new each deploy, soup

resolves and records each time and can roll the environment forward.

Either way, the thing that lands on the target is a digest, so a persistent remote deploy never silently drifts when someone re-pushes a tag. Resolution uses docker buildx imagetools inspect

with the agent's existing login. Two escape hatches: a floating ref that resolves to nothing is a hard error (never deploy stale), and --no-resolve

deploys references as-is — needed in the local loop where you kaupang build

an image that was never pushed.

The ledger stores the exact artifact and commands that were applied, so rollback is just "replay a known-good snapshot":

kaupang rollback api --list                       # show history with digests
kaupang rollback api                              # re-apply the previous good deploy
kaupang rollback api --to 20260611T140000Z-bbbb   # roll to a specific deployment
kaupang rollback api --dry-run                    # print the replay, run nothing

Because the target is declarative, rollback runs the same path as up

— no reconciler, no diffing. Each rollback is itself recorded, so you can roll back a rollback.

up --output json

prints what was actually deployed — including the digest each floating ref resolved to — as clean JSON on stdout (progress logs are suppressed). That's the explicit "resolve once in staging, promote the same digest to prod" handoff:

kaupang up api --target staging --output json > resolved.json
{
  "project": "longhall",
  "target": "staging",
  "backend": "swarm",
  "deployments": [
    { "environment": "api", "deploymentId": "20260612T…-ab12", "ranAt": "…",
      "images": [ { "service": "web", "ref": "ghcr.io/midgard/longhall-api:latest",
                    "digest": "sha256:9f86d0…", "pinned": true } ] }
  ]
}

A later prod stage reads resolved.json

and deploys those digests, with no second resolution and no chance of a tag moving underneath you. --dry-run --output json

emits the planned graph (no digests) for inspection or gating instead.

Wrap any env value in secret("VAR")

and kaupang emits a reference instead of the value — so secrets never land in the generated artifact or the ledger:

import { defineEnvironment, secret } from "@kaupang/core";

export default defineEnvironment({
  services: {
    web: {
      image: "longhall-api",
      env: {
        LOG_LEVEL: "info",                  // literal — persisted (fine, aids rollback)
        API_KEY: secret("LONGHALL_JARL_KEY"),    // → resolved at runtime, never written
        DB_PASS: secret("PROD_DB_PASSWORD"),// container var DB_PASS ← host PROD_DB_PASSWORD
      },
    },
  },
});

Compose / Swarm: emitted as${LONGHALL_JARL_KEY}

and interpolated by Docker at runtime from the process env. kaupangvalidates up front that every referenced var is present and fails fast with a clear message if not — so you never deploy with a silently-empty secret.Kubernetes: emitted as asecretKeyRef

into aSecret

named<project>-secrets

(key = the source var), which you manage in the cluster (e.g.kubectl create secret

, External Secrets, or a CSI driver). The value never enters the manifest.

Non-secret config (a literal string) is still persisted in the artifact and ledger on purpose — that's what makes rollback hermetic. Only marked values are held out.

  • Per service: pull: "always" | "missing" | "never"

→ Composepull_policy

. - Whole run: --pull always

→ Composeup --pull always

; Swarmstack deploy --resolve-image=always

(re-resolves tags to the newest digest). - Honest caveat: there is no portable "pull latest only if it exists" — docker pull

errors on a missing tag.--pull missing

is the safe default; registries without alatest

tag are fine as long as you pin tags.

defineEnvironment({
  hooks: {
    beforeUp: ["npm run build"],            // shell string, or { run, cwd, env }
    afterUp: [{ run: "./smoke-test.sh", cwd: "./scripts" }],
  },
  services: { web: { build: "./web" } },    // docker compose build picks this up
});

kaupang build <env>

runs docker compose build

for services that declare a build

context, tagging each as its (repository-prefixed) image

. Add --push

to docker compose push

them to the registry in the same step. Hooks run around up

/down

(beforeUp

/ afterUp

/ beforeDown

/ afterDown

).

kaupang operates on your image, not your source — so the service's language is irrelevant. Whatever turns your code into a container image (a Dockerfile

, buildpacks, ko

, …) is yours to keep; kaupang builds/pushes/deploys the result. A Python or C++ repo needs three small things: the Dockerfile

you'd have anyway, plus two plain-JS files (no TypeScript).

my-service/
├─ Dockerfile               # your build — Python, C++, Go, whatever
├─ kaupang.config.mjs
└─ environments/
   └─ app.mjs
// kaupang.config.mjs
export default {
  environments: "./environments",
  project: "my-service",
  dockerRepository: "ghcr.io/me",   // bare image names get this prefix
  targets: {
    local: {},                                              // your machine
    prod: { backend: "swarm", dockerContext: "prod-swarm", pull: "missing" },
  },
};
// environments/app.mjs
export default {
  services: {
    app: {
      image: "my-service",          // publish/deploy target → ghcr.io/me/my-service
      build: { context: "." },      // build from ./Dockerfile
      ports: ["8000:8000"],
    },
  },
};

A Python Dockerfile

is just the usual FROM python:3.12-slim

pip install

CMD ["uvicorn", "main:app", …]

; a C++ one is a multi-stage compile (FROM gcc

→ build → copy the binary into a slim runtime). kaupang doesn't care which — it only needs the resulting image tag. Then:

kaupang build app                 # docker compose build  → tags ghcr.io/me/my-service
kaupang build app --push          # build, then push it to the registry
kaupang up app                    # run it locally via compose
kaupang up app --target prod      # resolve the pushed image to a digest, deploy to swarm

That's the whole loop in one repo: build, publish, deploy — same commands locally and in CI. The catalog/solutions/bundle machinery is there when you grow into multiple services or sites, but a single service uses none of it.

Two levels, both topologically sorted with cycle detection: between environments (dependsOn

, resolved recursively) and between services within an environment. --dry-run

renders both, grouping services into parallelizable waves:

Environment start order:
  1. ● postgres
  2. ● redis
  3. ◆ api  ← depends on postgres, redis

  api
  ├─ wave 1: migrate
  └─ wave 2: parallel web, worker

| Backend | up | build | Notes | |---|---|---|---| compose | docker compose up -d --wait | docker compose build | Full support. | swarm | docker stack deploy | — (build + push in CI) | Uses the deploy key; cross-environment ordering handled by kaupang. | kubernetes | kubectl apply | — (build + push in CI) | Namespace + Deployment (+ Service) per service. Needs prebuilt images. |

kaupang runs as an ordinary step on a Microsoft-hosted or self-hosted agent. The pattern that gets the most value out of it:

Build & push the application image (immutable, build-id–tagged).Deploy to staging with kaupang — it resolves the tag to a digest, pins it, smoke-tests, and records the deployment.Promote to prod behind an approval — deploy thesametag, which resolves to thesamedigest staging validated.

Azure variables gotcha:non-secret pipeline / variable-group values are auto-exposed as environment variables, butsecretvariables are not — you must map them explicitly in the step'senv:

block (FOO: $(foo)

). kaupang (a Node process) and thedocker

/kubectl

it spawns read from that environment.

trigger:
  branches: { include: [ main ] }

variables:
  imageTag: $(Build.BuildId)          # immutable tag — the key to safe promotion
  registry: midgardregistry.azurecr.io

pool:
  vmImage: ubuntu-latest

stages:
  - stage: Build
    jobs:
      - job: build
        steps:
          - checkout: self
          - script: az acr login --name midgardregistry
            displayName: Registry login
          - script: |
              docker build -t $(registry)/longhall-api:$(imageTag) .
              docker push $(registry)/longhall-api:$(imageTag)
            displayName: Build & push image

  - stage: Staging
    dependsOn: Build
    jobs:
      - deployment: deployStaging
        environment: staging          # auto-deploys (no approval attached)
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self
                - task: NodeTool@0
                  inputs: { versionSpec: "20.x" }
                - script: npx @kaupang/cli up api --target staging
                  displayName: Deploy to staging
                  env:
                    LONGHALL_API_TAG: $(imageTag)
                    DOCKER_HOST: $(STAGING_DOCKER_HOST)   # secret → mapped here
                    LONGHALL_JARL_KEY: $(jarlKey)           # secret() ref → mapped here
                - script: ./scripts/smoke-test.sh https://staging.longhall.example
                  displayName: Smoke test

  - stage: Production
    dependsOn: Staging
    jobs:
      - deployment: deployProd
        environment: production       # attach a manual-approval check in the UI
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self
                - task: NodeTool@0
                  inputs: { versionSpec: "20.x" }
                - script: npx @kaupang/cli up api --target prod
                  displayName: Promote to production
                  env:
                    LONGHALL_API_TAG: $(imageTag)
                    KUBECONFIG: $(PROD_KUBECONFIG)        # secret file path

The matching environment file reads the tag from the pipeline:

// environments/api.ts
export default defineEnvironment({
  services: {
    web: { image: `longhall-api:${process.env.LONGHALL_API_TAG ?? "latest"}`, ports: ["8080:3000"] },
  },
});

The production

Azure DevOps Environment is where you attach the approval/check, so the promote stage waits for a human before kaupang up api --target prod

runs.

pr:
  branches: { include: [ main ] }

pool: { vmImage: ubuntu-latest }

steps:
  - checkout: self
  - task: NodeTool@0
    inputs: { versionSpec: "20.x" }
  - script: npx @kaupang/cli up api --target staging --dry-run
    displayName: Validate deploy plan

--dry-run

is hermetic (no registry calls, no deploy), so it's a fast, safe pre-merge check that the graph resolves and the commands look right.

trigger: none

parameters:
  - name: target
    type: string
    default: prod
  - name: to
    type: string
    default: ""          # empty = previous good deployment

pool: { vmImage: ubuntu-latest }

steps:
  - checkout: self
  - task: NodeTool@0
    inputs: { versionSpec: "20.x" }
  - script: az aks get-credentials --name midgard-${{ parameters.target }} --resource-group midgard
    displayName: Cluster login
  - script: |
      if [ -n "${{ parameters.to }}" ]; then
        npx @kaupang/cli rollback api --target ${{ parameters.target }} --to ${{ parameters.to }}
      else
        npx @kaupang/cli rollback api --target ${{ parameters.target }}
      fi
    displayName: Roll back api

For rollback to find history, the ledger must persist between runs (an agent workspace is ephemeral). Commit it to a deploy repo, publish it as a pipeline artifact, or — recommended — point

cacheDir

at a mounted volume on a self-hosted agent so the ledger survives.

If kaupang config lives in a dedicated deploy repo, check both out:

resources:
  repositories:
    - repository: deploy
      type: git
      name: midgard/deploy

steps:
  - checkout: self        # the application repo
  - checkout: deploy      # the kaupang config + environments
  - script: npx @kaupang/cli up api --target staging --cwd $(Build.SourcesDirectory)/deploy
    env: { LONGHALL_API_TAG: $(imageTag) }

The app repo publishes immutable images; the deploy repo declares and rolls them out. kaupang's catalog is the natural seam between them: the app's publish pipeline registers longhall-api → …@digest

, and the deploy repo's use("longhall-api")

picks it up.

kaupang up <env>          [-b ...] [--target <t>] [--pull ...] [--no-resolve] [--dry-run] [--output json] [--cwd <dir>]
kaupang up --solution <s> [--target <t>] [--pull ...] [--no-resolve] [--dry-run] [--output json] [--cwd <dir>]
kaupang up --bundle <dir|oci://ref> [--target <t>] [--dry-run] [--cwd <dir>]   # offline / airgap
kaupang down <env>        [-b ...] [--target <t>] [--with-deps] [--dry-run] [--cwd <dir>]
kaupang build <env>       [-b ...] [--push] [--dry-run] [--cwd <dir>]
kaupang bundle <solution> [--target <t>] [--with-images] [--push oci://ref] [-o <dir>] [--no-resolve] [--cwd <dir>]
kaupang rollback <env>    [--to <id>] [--target <t>] [--list] [--dry-run] [--cwd <dir>]
kaupang run <pipeline>    [--target <t>] [--dry-run] [--cwd <dir>]

--target

defaults to local

(or $KAUPANG_TARGET

). --cwd

resolves the config from a directory other than the current one.

npm install
npm run dev -- up web --dry-run --cwd examples/minimal   # run from source via jiti
npm run build                                            # bundle to dist/ with tsup
npm run typecheck
npm test                                                 # vitest suite (offline)

The examples/ folder is a gallery of runnable samples, each named for the one concept it isolates —

minimal

, build-from-dockerfile

, runonce-migrations

, catalog-presets

, secrets

, kubernetes

, pipeline

, and json-config

. Run any with --cwd examples/<folder>

.The flagship is examples/longhall/ — a tiny Viking trading-post backend that exercises everything together: a

saga

store and a runes

cache (both from a file catalog) plus a market

that builds its image from a local Dockerfile

and depends on them, deployable to the local

, vanaheim

, and asgard

realms (targets). It also defines a longhall-full

solution and a voyage

pipeline.The core is in place: multi-backend deploys, targets, the catalog (file/http/service/oci), solutions, OCI bundles, pipelines, the ledger + rollback, and --output json

digest handoff. Ideas under consideration next:

Parallel execution within a pipeline wave— independent steps in the same wave currently run sequentially; they could run concurrently.** Surface the resolved public URL**on a successfulup

for quick smoke-testing.Per-site rollback bounds in airgaps— constrain rollback to the versions a bundle actually carried in.

kaupang stays an imperative push tool: it makes a target match your config, records what it did, and replays on request. It is deliberately not a continuous reconciler — if you need git-driven self-healing, reach for Argo CD or Flux. The niche here is great ergonomics and local/CI parity for teams that want to run a deploy, not operate a control plane.

── more in #developer-tools 4 stories · sorted by recency
── more on @kaupang 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/show-hn-kaupang-a-pu…] indexed:0 read:21min 2026-06-17 ·