{"slug": "show-hn-kaupang-a-push-based-deploy-cli-now-with-a-drag-and-drop-builder", "title": "Show HN: Kaupang – a push-based deploy CLI, now with a drag-and-drop builder", "summary": "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.", "body_md": "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. 🛶\n\n**Spin up environments on Docker Compose, Docker Swarm, or Kubernetes from a\nsingle TypeScript config — the same command on your laptop and in CI.**\n\nkaupang is an imperative, push-based deploy tool — an *environment maker*, not a\nreconciler. You run it, it makes a target match your config, it records exactly\nwhat it did, and you can replay or roll back. No control loop, no cluster agent.\n\n```\nkaupang up api --target staging      # build the plan, resolve images, deploy\nkaupang up api --dry-run             # show the dependency graph + commands, run nothing\nkaupang rollback api --target prod   # re-apply the previous good deployment\nkaupang down api                     # tear it back down\n```\n\n**One tool, laptop to pipeline.**`kaupang up api --target staging`\n\nis byte-for-byte the same locally and on an agent. Only the*target*(context, env, backend) differs.**Config is code.** Environments are TypeScript, so image tags and env values can come straight from`process.env`\n\n/ 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`\n\nreplays 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.\n\n[Quick start](#quick-start)[Config](#config)[Environments & services](#environments--services)[Catalog (shared presets)](#catalog-shared-service-presets)[Targets (staging vs prod)](#targets-staging-vs-prod)[Solutions & bundles](#solutions--bundles)[Pipelines](#pipelines)[Single-service repo](#using-kaupang-in-a-single-service-repo-python-c-anything)[Versioning: resolve, pin, record, roll back](#versioning-resolve-pin-record-roll-back)[Secrets](#secrets)[Pull behavior](#pull-behavior)[Build & hooks](#build--hooks)[Dependency resolution](#dependency-resolution)[Backends](#backends)[Using kaupang in Azure DevOps](#using-kaupang-in-azure-devops)[Command reference](#command-reference)[Develop](#develop)[Roadmap](#roadmap)\n\n```\nnpm install -g @kaupang/cli        # or use via npx in CI\n```\n\nCreate a `kaupang.config.ts`\n\nat your repo root and an `environments/`\n\nfolder:\n\n``` js\n// kaupang.config.ts\nimport { defineConfig } from \"@kaupang/core\";\n\nexport default defineConfig({\n  environments: \"./environments\",\n  project: \"longhall\",\n  defaultBackend: \"compose\",\n});\njs\n// environments/api.ts\nimport { defineEnvironment } from \"@kaupang/core\";\n\nexport default defineEnvironment({\n  services: {\n    web: { image: \"ghcr.io/midgard/longhall-api:latest\", ports: [\"8080:3000\"] },\n  },\n});\nkaupang up api            # deploys locally with docker compose\nkaupang up api --dry-run  # preview the plan first\njs\nimport { defineConfig } from \"@kaupang/core\";\n\nexport default defineConfig({\n  environments: \"./environments\",     // folder of environment files\n  project: \"longhall\",                    // namespaces stacks / projects / namespaces\n  defaultBackend: \"compose\",          // compose | swarm | kubernetes\n  dockerRepository: \"ghcr.io/midgard\",   // prefix applied to bare image names\n  defaultPull: \"missing\",             // always | missing | never\n  cacheDir: \".kaupang\",               // where generated files + the ledger live\n  globalEnv: { TZ: \"UTC\" },           // injected into every service everywhere\n  catalog: { sources: [{ type: \"file\", path: \"./catalog.json\" }] },\n  targets: { /* see Targets */ },\n});\n```\n\nEnv var precedence (low → high): `globalEnv`\n\n< environment `env`\n\n< target `env`\n\n<\nservice `env`\n\n.\n\nNot a TypeScript repo?You don't need one. kaupang loads its config from`kaupang.config.ts`\n\n,`.mjs`\n\n,`.js`\n\n,or(and`.json`\n\n`environments/*`\n\nin any of those) — no`tsconfig`\n\n, no`typescript`\n\ndependency, no build step. Pick what fits:\n\n— full type-checking + autocomplete (the extension is transpiled at runtime).`.ts`\n\n— plain JavaScript objects, with comments and the`.js`\n\n/`.mjs`\n\n`secret()`\n\n/`use()`\n\nhelpers. Add`/** @type {import('kaupang').KaupangConfig} */`\n\nfor checking.— pure data, ideal for machine-generated configs. The helpers become literal tags:`.json`\n\n`secret(\"X\")`\n\n→`{ \"$secret\": \"X\" }`\n\n,`use(\"postgres\")`\n\n→`{ \"$catalog\": \"postgres\" }`\n\n. (No comments, and you write the tags by hand.)The\n\n`define*`\n\nhelpers are identity functions, so a plain object — or a JSON file — is all they ever produce.\n\nAn environment is a `docker-compose`\n\n-like definition, but in TypeScript. The\nergonomic surface is deliberately small:\n\n``` js\nimport { defineEnvironment, use } from \"@kaupang/core\";\n\nexport default defineEnvironment({\n  dependsOn: \"postgres\",                  // string or string[] — other environments\n  services: {\n    db: use(\"postgres\"),                  // a whole service from the catalog\n    web: \"longhall-api:latest\",               // string shorthand → { image: \"longhall-api:latest\" }\n    worker: { image: \"longhall-api\", dependsOn: \"web\" },\n  },\n});\n```\n\n- A service can be a plain\n**string**(the image), a full** object**, or a catalog reference via.`use()`\n\n`dependsOn`\n\naccepts a string or an array, at both the environment and service level.- With\n`dockerRepository`\n\nset,**bare** names (`longhall-api`\n\n) get prefixed (`ghcr.io/midgard/longhall-api`\n\n). Fully-qualified names (anything with a`/`\n\n) and catalog presets are left alone, so public images stay public.\n\nBecause the file is TypeScript, dynamic values just work:\n\n```\nweb: { image: `longhall-api:${process.env.LONGHALL_API_TAG ?? \"latest\"}` }\n```\n\nThat single line is what lets a pipeline pin a build by setting one env var.\n\nReusable service definitions that live **outside** your repo and are pulled in at\nload time — so a brand-new environment is a few `use()`\n\ncalls plus your own\nservice. A catalog is a manifest of named presets:\n\n```\n{\n  \"services\": {\n    \"postgres\": { \"image\": \"postgres:16-alpine\", \"ports\": [\"5432:5432\"],\n                  \"healthcheck\": { \"test\": [\"CMD-SHELL\", \"pg_isready\"] } },\n    \"redis\":    { \"image\": \"redis:7-alpine\", \"ports\": [\"6379:6379\"] }\n  }\n}\n```\n\nReference presets with `use(\"name\", overrides?)`\n\n. Sources are pluggable\n(`src/catalog/source.ts`\n\nimplements `CatalogSource`\n\n); earlier sources win:\n\n| source | status | use it for |\n|---|---|---|\n`file` |\nshipped | a manifest committed to a shared infra repo / vendored |\n`http` |\nshipped | a static manifest served by an internal endpoint |\n`service` |\nshipped | a running internal catalog service kaupang queries |\n`oci` |\nshipped | a versioned catalog published as an OCI artifact (ORAS) |\n\nThe `service`\n\nsource (a long-running internal catalog — e.g. a container your\nplatform team owns) just needs to answer a GET with JSON in this shape; both keys\nare optional, so one service can serve presets and solutions together:\n\n```\n// GET https://catalog.internal/  →\n{\n  \"services\":  { \"postgres\": { \"image\": \"postgres:16-alpine\", \"ports\": [\"5432:5432\"] } },\n  \"solutions\": { \"hedeby\": { \"version\": \"2.3.1\", \"environments\": [\"api\"],\n                                 \"pins\": { \"api.web\": \"longhall-api@sha256:…\" } } }\n}\ncatalog: { sources: [{ type: \"service\", url: \"https://catalog.internal\", headers: { Authorization: \"Bearer …\" } }] }\n```\n\nThat single service is the \"container that handles all things\" — your shared\nservice presets *and* the solution recipes teams compose on the fly.\n\nIf you'd rather version the catalog as an immutable artifact in a registry you\nalready run (ACR, Harbor, ghcr, ECR …), publish it with [ORAS](https://oras.land) and reference it\nby `ref`\n\n. No new infrastructure — it reuses your image registry.\n\n```\n# 1. Author the manifest (services and/or solutions).\ncat > catalog.json <<'JSON'\n{ \"services\":  { \"postgres\": { \"image\": \"postgres:16-alpine\", \"ports\": [\"5432:5432\"] } },\n  \"solutions\": { \"hedeby\": { \"version\": \"2.3.1\", \"environments\": [\"api\"] } } }\nJSON\n\n# 2. Authenticate to the registry (or `docker login` — oras reuses it).\noras login midgardregistry.azurecr.io -u <user> -p <token>\n\n# 3. Push it as a versioned OCI artifact.\noras push midgardregistry.azurecr.io/kaupang-catalog:1 catalog.json\ncatalog: {\n  sources: [{ type: \"oci\", ref: \"midgardregistry.azurecr.io/kaupang-catalog:1\" }],\n}\n```\n\nkaupang pulls it with `oras pull`\n\nat load time, using your existing\n`oras`\n\n/`docker login`\n\ncredentials — the same auth story as image resolution, so\nthere's nothing extra to set up. By default it reads `catalog.json`\n\nfrom the\nartifact; push a different filename and set it via the source's `path`\n\n. Pin a tag\n(or a digest) for reproducibility, exactly like images.\n\nA **target** is where kaupang deploys: a remote cluster, a remote host, or your\nmachine. The *same* environment definition deploys to every target — only the\ntarget's backend, env, and context differ. No duplicated environment files to\ndrift.\n\n```\ntargets: {\n  local: {},                                   // your default docker context\n  staging: {\n    backend: \"swarm\",\n    dockerContext: \"staging-swarm\",            // → docker --context staging-swarm …\n    pull: \"always\",                            // floating channel, roll forward\n    env: { APP_ENV: \"staging\", PUBLIC_URL: \"https://staging.longhall.example\" },\n  },\n  prod: {\n    backend: \"kubernetes\",\n    kubeContext: \"aks-prod\",                   // → kubectl --context aks-prod …\n    kubeconfig: \"./kubeconfig-prod\",           // sets KUBECONFIG for the run\n    pull: \"missing\",                           // deploy the digest staging validated\n    env: { APP_ENV: \"production\", PUBLIC_URL: \"https://longhall.example\" },\n  },\n},\nkaupang up api --target staging   # docker --context staging-swarm stack deploy …\nkaupang up api --target prod      # kubectl --context aks-prod apply …\n```\n\nContext selection rides on the tools' own mechanisms — `docker --context`\n\n/\n`DOCKER_HOST`\n\nand `kubectl --context`\n\n/ `KUBECONFIG`\n\n. Authenticate in your\npipeline as usual (`az acr login`\n\n, `az aks get-credentials`\n\n) and kaupang inherits\nit. An unknown `--target`\n\nis a hard error that lists the configured ones. The\nledger is scoped per `environment@target`\n\n, so staging and prod keep separate\nhistories and roll back independently.\n\nA **solution** is a named, versioned composition of environments — a whole\ncustomized product for a customer or site. Recipes can live inline in config or,\nmore usefully, in the hosted catalog (so they're composed on the fly without a\nrepo):\n\n```\nsolutions: {\n  \"hedeby\": {\n    version: \"2.3.1\",\n    environments: [\"birka\", \"ribe\"], // dependencies pulled in automatically\n    pins: { \"birka.web\": \"longhall-api@sha256:…\" }, // per-service version pins\n    env: { JARL: \"x\" },\n    target: \"site-x\",\n  },\n}\n```\n\nDeploy a solution directly (online — resolves images and deploys):\n\n```\nkaupang up --solution hedeby --target site-x\n```\n\nOr **bundle** it into a portable, fully-pinned artifact — the unit a pipeline\nproduces, and what crosses an air gap:\n\n```\nkaupang bundle hedeby --target site-x --with-images -o ./hedeby.bundle\n```\n\nA bundle is self-contained and relocatable: it holds the generated artifacts and\ncommands with bundle-relative paths, plus (with `--with-images`\n\n) `docker save`\n\nd\nimage tarballs. Deploy it with no source repo and no registry:\n\n```\n# inside an airgapped network, after extracting the 7z:\nkaupang up --bundle ./hedeby.bundle --target site-x\n```\n\nOr 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:\n\n```\nkaupang bundle hedeby --target site-x --push oci://midgardregistry.azurecr.io/bundles/hedeby:2.3.1\nkaupang up --bundle oci://midgardregistry.azurecr.io/bundles/hedeby:2.3.1 --target site-x\n```\n\nPush/pull use `oras`\n\n(same auth as the OCI catalog source), so a solution becomes\na versioned, pullable thing in the registry you already run. `up --bundle`\n\naccepts\neither a local directory or an `oci://`\n\nref.\n\n`up --bundle`\n\nloads any included images and replays the pinned artifacts through\nthe target's context — the same deploy path as everything else, just offline. It\nrecords to the ledger like any deploy, so rollback works inside the gap too\n(bounded by which versions were carried in).\n\nThe chain: service presets → solution recipe (catalog) → bundle (pipeline, pinned\n\n- images) → target → live URL. Online and airgapped use the\n*same*bundle artifact; only the transport differs.\n\nA pipeline is an ordered set of steps — shell commands, deploys, builds, and waits\n— wired together as a DAG. It's the *recipe* part of CI: the portable sequence of\nkaupang actions that runs byte-for-byte the same on your laptop and on an agent.\nThe CI system still owns triggers, approvals, and secrets; the pipeline owns what\nactually happens.\n\n``` js\nimport { defineConfig, definePipeline } from \"@kaupang/core\";\n\nexport default defineConfig({\n  // …environments, targets…\n  pipelines: {\n    release: definePipeline({\n      steps: {\n        build:  { run: \"npm run build\" },\n        deploy: { up: \"api\", needs: \"build\" },\n        smoke:  { wait: { http: \"http://localhost:8080/health\", timeout: \"30s\" }, needs: \"deploy\" },\n        done:   { run: \"echo released\", needs: \"smoke\" },\n      },\n    }),\n  },\n});\nkaupang run release --target staging\nkaupang run release --dry-run     # print the step DAG, run nothing\n```\n\nEach step declares exactly one action and optional `needs`\n\n:\n\n| action | does |\n|---|---|\n`run: \"<cmd>\"` |\nruns a shell command from the config root |\n`up: \"<env>\"` |\ndeploys an environment — same path as `kaupang up <env>` |\n`down: \"<env>\"` |\ntears an environment down |\n`build: \"<env>\"` |\nbuilds the environment's images |\n`wait: { … }` |\ngates on `http` (poll a URL for a status) or `seconds` (sleep) |\n\nSteps are topologically sorted from `needs`\n\n; cycles are rejected up front, and the\ndry-run shows which steps fall in the same wave (independent, so safe to parallelize\nlater). `up`\n\n/ `down`\n\n/ `build`\n\nsteps honor `--target`\n\n(or a per-step `target`\n\n/\n`backend`\n\noverride) and go through the identical resolve → pin → materialize →\nledger path as the top-level commands — a pipeline `up`\n\nis a real, recorded deploy,\nnot a second code path. `wait`\n\nexample:\n\n```\nsmoke: { wait: { http: \"https://api.longhall.example/health\", status: 200, interval: \"2s\", timeout: \"2m\" }, needs: \"deploy\" }\n```\n\nIn Azure DevOps this is one step inside a stage — `npx @kaupang/cli run release --target staging`\n\n— so approvals/gates stay in the YAML while the deploy recipe stays in\nkaupang and stays runnable locally.\n\nEvery `up`\n\nresolves each service's image reference to an immutable **digest** at\ndeploy time, pins that digest into the generated artifact, and records the whole\ndeployment in a ledger (`.kaupang/ledger.json`\n\n). One mechanism supports both\nintents — you choose by what you write:\n\n**Pinned**—`image: \"longhall-api@sha256:…\"`\n\nor`use(\"longhall-api@1.4.2\")`\n\n. A named version; re-running`up`\n\nwon't roll forward unless you edit the file.**Floating**—`image: \"longhall-api:latest\"`\n\n, a channel like`@stable`\n\n, or`use(\"longhall-api\")`\n\n. May point somewhere new each deploy, so`up`\n\nresolves and records each time and can roll the environment forward.\n\nEither way, the thing that lands on the target is a digest, so a persistent\nremote deploy never silently drifts when someone re-pushes a tag. Resolution uses\n`docker buildx imagetools inspect`\n\nwith the agent's existing login. Two escape\nhatches: a floating ref that resolves to nothing is a hard error (never deploy\nstale), and `--no-resolve`\n\ndeploys references as-is — needed in the local loop\nwhere you `kaupang build`\n\nan image that was never pushed.\n\nThe ledger stores the exact artifact and commands that were applied, so rollback is just \"replay a known-good snapshot\":\n\n```\nkaupang rollback api --list                       # show history with digests\nkaupang rollback api                              # re-apply the previous good deploy\nkaupang rollback api --to 20260611T140000Z-bbbb   # roll to a specific deployment\nkaupang rollback api --dry-run                    # print the replay, run nothing\n```\n\nBecause the target is declarative, rollback runs the same path as `up`\n\n— no\nreconciler, no diffing. Each rollback is itself recorded, so you can roll back a\nrollback.\n\n`up --output json`\n\nprints what was actually deployed — including the digest each\nfloating ref resolved to — as clean JSON on stdout (progress logs are suppressed).\nThat's the explicit \"resolve once in staging, promote the *same* digest to prod\"\nhandoff:\n\n```\n# staging stage: deploy and capture exactly what landed\nkaupang up api --target staging --output json > resolved.json\n{\n  \"project\": \"longhall\",\n  \"target\": \"staging\",\n  \"backend\": \"swarm\",\n  \"deployments\": [\n    { \"environment\": \"api\", \"deploymentId\": \"20260612T…-ab12\", \"ranAt\": \"…\",\n      \"images\": [ { \"service\": \"web\", \"ref\": \"ghcr.io/midgard/longhall-api:latest\",\n                    \"digest\": \"sha256:9f86d0…\", \"pinned\": true } ] }\n  ]\n}\n```\n\nA later prod stage reads `resolved.json`\n\nand deploys those digests, with no second\nresolution and no chance of a tag moving underneath you. `--dry-run --output json`\n\nemits the planned graph (no digests) for inspection or gating instead.\n\nWrap any env value in `secret(\"VAR\")`\n\nand kaupang emits a **reference** instead of\nthe value — so secrets never land in the generated artifact or the ledger:\n\n``` js\nimport { defineEnvironment, secret } from \"@kaupang/core\";\n\nexport default defineEnvironment({\n  services: {\n    web: {\n      image: \"longhall-api\",\n      env: {\n        LOG_LEVEL: \"info\",                  // literal — persisted (fine, aids rollback)\n        API_KEY: secret(\"LONGHALL_JARL_KEY\"),    // → resolved at runtime, never written\n        DB_PASS: secret(\"PROD_DB_PASSWORD\"),// container var DB_PASS ← host PROD_DB_PASSWORD\n      },\n    },\n  },\n});\n```\n\n**Compose / Swarm:** emitted as`${LONGHALL_JARL_KEY}`\n\nand interpolated by Docker at runtime from the process env. kaupang**validates 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 a`secretKeyRef`\n\ninto a`Secret`\n\nnamed`<project>-secrets`\n\n(key = the source var), which you manage in the cluster (e.g.`kubectl create secret`\n\n, External Secrets, or a CSI driver). The value never enters the manifest.\n\nNon-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.\n\n- Per service:\n`pull: \"always\" | \"missing\" | \"never\"`\n\n→ Compose`pull_policy`\n\n. - Whole run:\n`--pull always`\n\n→ Compose`up --pull always`\n\n; Swarm`stack deploy --resolve-image=always`\n\n(re-resolves tags to the newest digest). - Honest caveat: there is no portable \"pull latest only if it exists\" —\n`docker pull`\n\nerrors on a missing tag.`--pull missing`\n\nis the safe default; registries without a`latest`\n\ntag are fine as long as you pin tags.\n\n```\ndefineEnvironment({\n  hooks: {\n    beforeUp: [\"npm run build\"],            // shell string, or { run, cwd, env }\n    afterUp: [{ run: \"./smoke-test.sh\", cwd: \"./scripts\" }],\n  },\n  services: { web: { build: \"./web\" } },    // docker compose build picks this up\n});\n```\n\n`kaupang build <env>`\n\nruns `docker compose build`\n\nfor services that declare a\n`build`\n\ncontext, tagging each as its (repository-prefixed) `image`\n\n. Add `--push`\n\nto `docker compose push`\n\nthem to the registry in the same step. Hooks run around\n`up`\n\n/`down`\n\n(`beforeUp`\n\n/ `afterUp`\n\n/ `beforeDown`\n\n/ `afterDown`\n\n).\n\nkaupang operates on your **image**, not your source — so the service's language is\nirrelevant. Whatever turns your code into a container image (a `Dockerfile`\n\n,\nbuildpacks, `ko`\n\n, …) is yours to keep; kaupang builds/pushes/deploys the result.\nA Python or C++ repo needs three small things: the `Dockerfile`\n\nyou'd have anyway,\nplus two plain-JS files (no TypeScript).\n\n```\nmy-service/\n├─ Dockerfile               # your build — Python, C++, Go, whatever\n├─ kaupang.config.mjs\n└─ environments/\n   └─ app.mjs\n// kaupang.config.mjs\nexport default {\n  environments: \"./environments\",\n  project: \"my-service\",\n  dockerRepository: \"ghcr.io/me\",   // bare image names get this prefix\n  targets: {\n    local: {},                                              // your machine\n    prod: { backend: \"swarm\", dockerContext: \"prod-swarm\", pull: \"missing\" },\n  },\n};\n// environments/app.mjs\nexport default {\n  services: {\n    app: {\n      image: \"my-service\",          // publish/deploy target → ghcr.io/me/my-service\n      build: { context: \".\" },      // build from ./Dockerfile\n      ports: [\"8000:8000\"],\n    },\n  },\n};\n```\n\nA Python `Dockerfile`\n\nis just the usual `FROM python:3.12-slim`\n\n→ `pip install`\n\n→\n`CMD [\"uvicorn\", \"main:app\", …]`\n\n; a C++ one is a multi-stage compile (`FROM gcc`\n\n→\nbuild → copy the binary into a slim runtime). kaupang doesn't care which — it only\nneeds the resulting image tag. Then:\n\n```\nkaupang build app                 # docker compose build  → tags ghcr.io/me/my-service\nkaupang build app --push          # build, then push it to the registry\nkaupang up app                    # run it locally via compose\nkaupang up app --target prod      # resolve the pushed image to a digest, deploy to swarm\n```\n\nThat'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.\n\nTwo levels, both topologically sorted with cycle detection: between environments\n(`dependsOn`\n\n, resolved recursively) and between services within an environment.\n`--dry-run`\n\nrenders both, grouping services into parallelizable waves:\n\n```\nEnvironment start order:\n  1. ● postgres\n  2. ● redis\n  3. ◆ api  ← depends on postgres, redis\n\n  api\n  ├─ wave 1: migrate\n  └─ wave 2: parallel web, worker\n```\n\n| Backend | `up` |\n`build` |\nNotes |\n|---|---|---|---|\n`compose` |\n`docker compose up -d --wait` |\n`docker compose build` |\nFull support. |\n`swarm` |\n`docker stack deploy` |\n— (build + push in CI) | Uses the `deploy` key; cross-environment ordering handled by kaupang. |\n`kubernetes` |\n`kubectl apply` |\n— (build + push in CI) | Namespace + Deployment (+ Service) per service. Needs prebuilt images. |\n\nkaupang runs as an ordinary step on a Microsoft-hosted or self-hosted agent. The pattern that gets the most value out of it:\n\n**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 the*same*tag, which resolves to the*same*digest staging validated.\n\nAzure 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's`env:`\n\nblock (`FOO: $(foo)`\n\n). kaupang (a Node process) and the`docker`\n\n/`kubectl`\n\nit spawns read from that environment.\n\n```\n# azure-pipelines.yml\ntrigger:\n  branches: { include: [ main ] }\n\nvariables:\n  imageTag: $(Build.BuildId)          # immutable tag — the key to safe promotion\n  registry: midgardregistry.azurecr.io\n\npool:\n  vmImage: ubuntu-latest\n\nstages:\n  - stage: Build\n    jobs:\n      - job: build\n        steps:\n          - checkout: self\n          - script: az acr login --name midgardregistry\n            displayName: Registry login\n          - script: |\n              docker build -t $(registry)/longhall-api:$(imageTag) .\n              docker push $(registry)/longhall-api:$(imageTag)\n            displayName: Build & push image\n\n  - stage: Staging\n    dependsOn: Build\n    jobs:\n      - deployment: deployStaging\n        environment: staging          # auto-deploys (no approval attached)\n        strategy:\n          runOnce:\n            deploy:\n              steps:\n                - checkout: self\n                - task: NodeTool@0\n                  inputs: { versionSpec: \"20.x\" }\n                # Resolves longhall-api:$(imageTag) → a digest, pins it, records it.\n                - script: npx @kaupang/cli up api --target staging\n                  displayName: Deploy to staging\n                  env:\n                    LONGHALL_API_TAG: $(imageTag)\n                    DOCKER_HOST: $(STAGING_DOCKER_HOST)   # secret → mapped here\n                    LONGHALL_JARL_KEY: $(jarlKey)           # secret() ref → mapped here\n                - script: ./scripts/smoke-test.sh https://staging.longhall.example\n                  displayName: Smoke test\n\n  - stage: Production\n    dependsOn: Staging\n    jobs:\n      - deployment: deployProd\n        environment: production       # attach a manual-approval check in the UI\n        strategy:\n          runOnce:\n            deploy:\n              steps:\n                - checkout: self\n                - task: NodeTool@0\n                  inputs: { versionSpec: \"20.x\" }\n                # Same immutable tag ⇒ same digest staging validated.\n                - script: npx @kaupang/cli up api --target prod\n                  displayName: Promote to production\n                  env:\n                    LONGHALL_API_TAG: $(imageTag)\n                    KUBECONFIG: $(PROD_KUBECONFIG)        # secret file path\n                    # API_KEY comes from the in-cluster <project>-secrets Secret on k8s\n```\n\nThe matching environment file reads the tag from the pipeline:\n\n```\n// environments/api.ts\nexport default defineEnvironment({\n  services: {\n    web: { image: `longhall-api:${process.env.LONGHALL_API_TAG ?? \"latest\"}`, ports: [\"8080:3000\"] },\n  },\n});\n```\n\nThe `production`\n\nAzure DevOps Environment is where you attach the approval/check,\nso the promote stage waits for a human before `kaupang up api --target prod`\n\nruns.\n\n```\n# azure-pipelines-pr.yml\npr:\n  branches: { include: [ main ] }\n\npool: { vmImage: ubuntu-latest }\n\nsteps:\n  - checkout: self\n  - task: NodeTool@0\n    inputs: { versionSpec: \"20.x\" }\n  # Fails the PR if the config doesn't resolve; prints the dependency graph.\n  - script: npx @kaupang/cli up api --target staging --dry-run\n    displayName: Validate deploy plan\n```\n\n`--dry-run`\n\nis hermetic (no registry calls, no deploy), so it's a fast,\nsafe pre-merge check that the graph resolves and the commands look right.\n\n```\n# azure-pipelines-rollback.yml  (manual trigger)\ntrigger: none\n\nparameters:\n  - name: target\n    type: string\n    default: prod\n  - name: to\n    type: string\n    default: \"\"          # empty = previous good deployment\n\npool: { vmImage: ubuntu-latest }\n\nsteps:\n  - checkout: self\n  - task: NodeTool@0\n    inputs: { versionSpec: \"20.x\" }\n  - script: az aks get-credentials --name midgard-${{ parameters.target }} --resource-group midgard\n    displayName: Cluster login\n  - script: |\n      if [ -n \"${{ parameters.to }}\" ]; then\n        npx @kaupang/cli rollback api --target ${{ parameters.target }} --to ${{ parameters.to }}\n      else\n        npx @kaupang/cli rollback api --target ${{ parameters.target }}\n      fi\n    displayName: Roll back api\n```\n\nFor 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\n\n`cacheDir`\n\nat a mounted volume on a self-hosted agent so the ledger survives.\n\nIf kaupang config lives in a dedicated deploy repo, check both out:\n\n```\nresources:\n  repositories:\n    - repository: deploy\n      type: git\n      name: midgard/deploy\n\nsteps:\n  - checkout: self        # the application repo\n  - checkout: deploy      # the kaupang config + environments\n  - script: npx @kaupang/cli up api --target staging --cwd $(Build.SourcesDirectory)/deploy\n    env: { LONGHALL_API_TAG: $(imageTag) }\n```\n\nThe app repo publishes immutable images; the deploy repo declares and rolls them\nout. kaupang's catalog is the natural seam between them: the app's publish\npipeline registers `longhall-api → …@digest`\n\n, and the deploy repo's `use(\"longhall-api\")`\n\npicks it up.\n\n```\nkaupang up <env>          [-b ...] [--target <t>] [--pull ...] [--no-resolve] [--dry-run] [--output json] [--cwd <dir>]\nkaupang up --solution <s> [--target <t>] [--pull ...] [--no-resolve] [--dry-run] [--output json] [--cwd <dir>]\nkaupang up --bundle <dir|oci://ref> [--target <t>] [--dry-run] [--cwd <dir>]   # offline / airgap\nkaupang down <env>        [-b ...] [--target <t>] [--with-deps] [--dry-run] [--cwd <dir>]\nkaupang build <env>       [-b ...] [--push] [--dry-run] [--cwd <dir>]\nkaupang bundle <solution> [--target <t>] [--with-images] [--push oci://ref] [-o <dir>] [--no-resolve] [--cwd <dir>]\nkaupang rollback <env>    [--to <id>] [--target <t>] [--list] [--dry-run] [--cwd <dir>]\nkaupang run <pipeline>    [--target <t>] [--dry-run] [--cwd <dir>]\n```\n\n`--target`\n\ndefaults to `local`\n\n(or `$KAUPANG_TARGET`\n\n). `--cwd`\n\nresolves the config\nfrom a directory other than the current one.\n\n```\nnpm install\nnpm run dev -- up web --dry-run --cwd examples/minimal   # run from source via jiti\nnpm run build                                            # bundle to dist/ with tsup\nnpm run typecheck\nnpm test                                                 # vitest suite (offline)\n```\n\nThe [ examples/](/kaupang-dev/kaupang/blob/main/examples) folder is a gallery of runnable samples, each named for\nthe one concept it isolates —\n\n`minimal`\n\n, `build-from-dockerfile`\n\n, `runonce-migrations`\n\n,\n`catalog-presets`\n\n, `secrets`\n\n, `kubernetes`\n\n, `pipeline`\n\n, and `json-config`\n\n. Run any with\n`--cwd examples/<folder>`\n\n.The flagship is [ examples/longhall/](/kaupang-dev/kaupang/blob/main/examples/longhall) — a tiny Viking trading-post\nbackend that exercises everything together: a\n\n`saga`\n\nstore and a `runes`\n\ncache (both\nfrom a file catalog) plus a `market`\n\nthat builds its image from a local `Dockerfile`\n\nand depends on them, deployable to the `local`\n\n, `vanaheim`\n\n, and `asgard`\n\nrealms\n(targets). It also defines a `longhall-full`\n\nsolution and a `voyage`\n\npipeline.The core is in place: multi-backend deploys, targets, the catalog (file/http/service/oci),\nsolutions, OCI bundles, pipelines, the ledger + rollback, and `--output json`\n\ndigest\nhandoff. Ideas under consideration next:\n\n**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 successful`up`\n\nfor quick smoke-testing.**Per-site rollback bounds in airgaps**— constrain rollback to the versions a bundle actually carried in.\n\nkaupang stays an imperative push tool: it makes a target match your config,\nrecords what it did, and replays on request. It is deliberately *not* a\ncontinuous reconciler — if you need git-driven self-healing, reach for Argo CD or\nFlux. The niche here is great ergonomics and local/CI parity for teams that want\nto *run a deploy*, not operate a control plane.", "url": "https://wpnews.pro/news/show-hn-kaupang-a-push-based-deploy-cli-now-with-a-drag-and-drop-builder", "canonical_source": "https://github.com/kaupang-dev/kaupang", "published_at": "2026-06-17 12:29:04+00:00", "updated_at": "2026-06-17 12:53:09.516542+00:00", "lang": "en", "topics": ["developer-tools", "ai-infrastructure"], "entities": ["Kaupang", "Docker Compose", "Docker Swarm", "Kubernetes", "TypeScript", "Azure DevOps"], "alternates": {"html": "https://wpnews.pro/news/show-hn-kaupang-a-push-based-deploy-cli-now-with-a-drag-and-drop-builder", "markdown": "https://wpnews.pro/news/show-hn-kaupang-a-push-based-deploy-cli-now-with-a-drag-and-drop-builder.md", "text": "https://wpnews.pro/news/show-hn-kaupang-a-push-based-deploy-cli-now-with-a-drag-and-drop-builder.txt", "jsonld": "https://wpnews.pro/news/show-hn-kaupang-a-push-based-deploy-cli-now-with-a-drag-and-drop-builder.jsonld"}}