{"slug": "pilum-from-launch-to-production-ready-in-3-months", "title": "Pilum: From Launch to Production-Ready in 3 Months", "summary": "SID Technologies open-sourced Pilum, a multi-cloud deployment CLI, in December 2025 and made it production-ready within three months. The tool deploys to Cloud Run, Lambda, Azure, Cloudflare Pages, npm, Homebrew, and Docker Hub from a single configuration file. Key challenges included fixing wave-based deployment ordering, silent error swallowing in the worker queue, and Cloud Run secret format issues.", "body_md": "Github URL: [https://github.com/SID-Technologies/Pilum](https://github.com/SID-Technologies/Pilum)\n\nIn December 2025, I [open-sourced Pilum](https://logicalbytes.dev/blog/introducing-pilum), a multi-cloud deployment CLI that deploys to Cloud Run, Lambda, Azure, Cloudflare Pages, npm, Homebrew, and Docker Hub from a single `pilum.yaml`\n\n. The announcement post covered the architecture: recipes, ingredients, handlers, wave-based execution.\n\nThat was the \"it compiles and the tests pass\" version.\n\nThree months and 40+ pull requests later, Pilum deploys all of [SID Technologies](https://romans.dev), platform-core, Torch, Statio, every website, every npm package, and itself. This post is about everything that broke between \"it works\" and \"it ships production software.\"\n\n*(**Update:** there's now a seven-months-in addendum at the bottom. It's mostly about what stopped breaking.)*\n\n`--only-changed`\n\n, file embedding support. The \"I need this for real\" features. (#22-#24)The pattern is clear: **features ship fast, fixes ship faster, and the real bugs show up a month later.**\n\nWave-based deployment was the headline feature in #31. Services declare dependencies, Pilum builds a dependency graph, topologically sorts it into waves, and executes waves in parallel.\n\nIt worked perfectly in tests. Then I tried to publish npm packages.\n\nThe problem: Pilum publishes `@sid-technologies/base-ui`\n\n, which depends on `@repo/configs`\n\n. Both are in the same monorepo. Wave ordering correctly put `configs`\n\nbefore `base-ui`\n\n. But the worker allocation was distributing work across goroutines without respecting wave boundaries — workers from wave 2 could start before wave 1 fully drained.\n\nThe npm registry would reject `base-ui`\n\nbecause `configs`\n\nhadn't finished publishing yet. Sometimes it worked (race condition timing), sometimes it didn't.\n\nThe fix (#61) was two things: memory-based worker allocation (don't spawn more workers than the machine can handle) and a proper wave barrier, wave N+1 doesn't start until every worker in wave N has completed and reported success. The entire orchestrator got rewritten in #60 to make this possible — `runner.go`\n\n(772 lines) was replaced with `execution.go`\n\n, `pipeline.go`\n\n, `steps.go`\n\n, and `waves.go`\n\n(total: ~773 lines, but now testable and correct).\n\nThis was the scariest bug (#49). The worker queue was silently swallowing errors from shell commands. A deployment step would fail, the worker would log it, but the orchestrator wouldn't see the failure. The deploy would \"succeed\" with broken services.\n\nThe root cause: the command worker's error channel wasn't being read in all code paths. If a command failed during the capture phase (reading stdout/stderr), the error was logged but not propagated to the result channel.\n\nThe fix was straightforward propagate errors through every code path but finding it required adding 274 lines of tests to the worker queue and orchestrator. The bug was invisible because the services usually deployed fine. It only surfaced when a Docker build failed mid-stream and Pilum reported success anyway.\n\n**Lesson: if you're building a deployment tool, test the failure paths harder than the success paths. Nobody cares if deploys work. They care enormously when deploys claim to work but didn't.**\n\nGCP Cloud Run expects secrets in a specific format: `SECRET_NAME=projects/PROJECT/secrets/NAME/versions/latest`\n\n. Pilum was passing them as `SECRET_NAME=value`\n\n, which works for environment variables but not for secret references.\n\nTwo fixes (#48, #58):\n\n`--set-env-vars`\n\nfrom `--set-secrets`\n\nin the `gcloud run deploy`\n\ncommand.This is the kind of bug that only shows up when you deploy a real service with real secrets, not when you test against mocked GCP APIs.\n\nCloudflare Pages broke twice (#47, #56). The first fix was the recipe itself — the wrangler CLI changed its flags between versions and the recipe had hardcoded the old format.\n\nThe second fix was more subtle: the capture worker was incorrectly detecting the end of output from the wrangler process. It would sometimes truncate the deployment URL from the output, so Pilum couldn't report where the site was deployed. The fix was a one-line change in how the capture worker detects stream completion, but it took an hour to diagnose because wrangler's output format is inconsistent between deploy targets.\n\nThe YAML parser (#42) had an encoding issue. Users could write `build: go`\n\nin their pilum.yaml, but the YAML library parsed `go`\n\nas a boolean (`true`\n\nin YAML 1.1). This broke the build step because it expected a string, not a boolean.\n\nTwo-line fix: force string type on specific fields during parsing. But it's the kind of bug that makes you question every YAML-based config format you've ever designed.\n\nGetting npm packages to publish correctly took three separate PRs (#52, #53, #57):\n\n**#52**: The npm ingredient needed workspace resolution. In a pnpm monorepo, `pnpm publish`\n\nneeds to know which workspace you're publishing. Added a `resolve_workspaces.js`\n\nscript that walks the workspace config and finds the right package.\n\n**#53**: E2E test infrastructure. After #52 I realized I had no way to test the full publish flow without actually hitting the npm registry. Built a test harness with fixture packages and dry-run publishing.\n\n**#57**: `npm`\n\nvs `pnpm`\n\ncommand resolution. Some systems have `npm`\n\nbut not `pnpm`\n\n, or vice versa. The completion and local command system needed to detect which package manager is available and use the right one.\n\nThe December launch had the core recipe engine and a few deploy targets. Everything below was added in February because real usage demanded it:\n\nTen features in three weeks. Each one spawned 1-2 bug fixes in the following weeks.\n\nBy February 9, Pilum was stable enough that I didn't want to keep writing `curl | sh`\n\ninstall scripts in every CI workflow. So I built [pilum-action](https://github.com/SID-Technologies/Pilum-Action) — a GitHub Action that installs Pilum and runs any command.\n\n```\n- uses: SID-Technologies/pilum-action@v1\n  with:\n    command: deploy\n    tag: ${{ github.event.release.tag_name }}\n```\n\nIt auto-detects the runner's OS and architecture, downloads the right binary, and passes through all credentials via environment variables. Simple wrapper, but it cut 15-20 lines of boilerplate from every deployment workflow.\n\nThe action itself hit a bug immediately: the binary name in the release archive included the version number (`pilum-v0.3.1-linux-amd64`\n\n), but the action was looking for `pilum`\n\n. Two quick fixes (#3, #4) and a third one in March when the naming convention changed again.\n\nEvery SID repo now uses `pilum-action@v1`\n\nfor deployments instead of raw shell scripts.\n\nThe [pilum.dev](https://pilum.dev) website launched the same day as the CLI (December 3) — an Astro site with an animated terminal demo, deploy target icons, and full documentation.\n\nOver the next three months it evolved alongside the tool:\n\nThe website was always the last thing updated after a feature shipped. In hindsight, having docs deploy automatically from the same `pilum deploy`\n\npipeline would have kept them in sync. That's a future improvement.\n\nPR #51 was a comprehensive security audit response. The headline finding: **shell injection via pilum.yaml values.** If a service name or environment variable contained shell metacharacters (\n\n`$(rm -rf /)`\n\n), they'd be passed unsanitized to `sh -c`\n\n.The fix introduced a `shellutil`\n\npackage with:\n\n`Quote()`\n\n: POSIX single-quote escaping for string commands`ValidateServiceName()`\n\n: Reject names with shell metacharacters`SanitizeHeredocValue()`\n\n: Escape `$`\n\n, backticks, and backslashes in heredoc valuesAlso added: symlink traversal prevention, path validation, and safe temporary directory handling.\n\nPilum's attack surface is small — users run their own configs — but in CI/CD environments where configs might be generated or templates might include untrusted input, these protections matter.\n\nLater, we added Grype + Syft vulnerability scanning to the CI pipeline (#51 and subsequent). Every dependency is scanned against the NVD database, and builds fail on high-severity CVEs.\n\nFour months of dogfooding (December 2025 — April 2026):\n\n**E2E tests from day one.** Unit tests caught maybe 30% of the bugs on this list. Every serious bug — wave ordering, error swallowing, npm publishing, Cloudflare output — required the full pipeline to reproduce. I built the E2E framework in #53. Should have been #2.\n\n**Fewer features per sprint.** The Feb 7 feature marathon (8 features in 48 hours) created a week of bug fixes. Shipping 2-3 features with immediate dogfooding would have caught issues faster.\n\n**The orchestrator should have been modular from the start.** The 772-line `runner.go`\n\nwas unmaintainable. The rewrite into `execution.go`\n\n, `pipeline.go`\n\n, `steps.go`\n\n, and `waves.go`\n\nshould have been the original architecture. The monolithic runner made every bug harder to find and every fix riskier.\n\nPilum is stable. It deploys everything SID Technologies builds. The immediate roadmap:\n\nThe gap between \"open-source announcement\" and \"tool I trust with production deploys\" was about 40 pull requests. Every real deployment surfaced an edge case that unit tests never caught. The wave ordering bug was invisible for two months because most deploys don't have inter-service dependencies.\n\nIf you're building developer tools: **dogfood them immediately, not eventually.** The first month after launch is a second development phase. Budget for it.\n\nThe most interesting thing that happened to Pilum since April is what stopped happening.\n\nThe post above covers December through April: 62 pull requests, an orchestrator rewrite, a security audit, and a feature sprint that took a week of fixes to clean up. April through late July looks nothing like that. **15 commits: three features, ten small fixes, a refactor, and a chore.** v0.6 to v0.7.4. No rewrites. No multi-PR bug sagas. Nothing new for the \"everything that broke\" list.\n\nMeanwhile the surface area kept growing. Pilum deploys everything SID Technologies ships: GCP services, every website, Homebrew formulas, and now private npm packages too. All of CI/CD runs through [pilum-action](https://github.com/SID-Technologies/Pilum-Action). I stopped thinking about the tool. For a deploy tool, that's the whole job.\n\nTwo things from the quiet months are worth writing down.\n\n**The deploy config became the dev environment.** `pilum compose`\n\n(#65) reads the same `pilum.yaml`\n\nfiles that deploy to production and generates a `docker-compose.yml`\n\n: services wired with `depends_on`\n\n, healthchecks, and infrastructure dependencies declared once in `.pilum.yml`\n\n. For most of my projects that means a Postgres container plus my services. It works really well. I use it constantly while developing, spinning containers up and down with the same configs that ship.\n\nThe lesson came from the one project where it wasn't simple. I have a gateway service that routes to multiple backends, and a dependent project that lives in a different repo. I wanted all of those containers in one stack behind a single command, and I kept hitting port conflicts. The answer wasn't more generator features. Docker Compose already has an override convention: the generated `docker-compose.yml`\n\nstays regenerable, and a hand-written `docker-compose.override.yml`\n\ncarries the extra containers and the port fixes. Compose merges the two automatically.\n\nThere's a design principle in there: **when your tool generates a file, decide on day one where human edits go. They're coming, and if the answer is \"into the generated file,\" you built a one-way generator.**\n\n**Features started arriving with their failure paths tested.** The one substantial feature of this stretch was Cloud Run sidecars plus two image-based recipes for deploying prebuilt images (#72). It shipped with its own e2e fixtures and an end-to-end test runner in the same PR. It spawned zero follow-up fixes. In February, a feature that size cost me two weeks of cleanup. In the \"what I'd do differently\" list above, I said the E2E framework should have been #2 instead of #53. Turns out following your own advice works.\n\nThe February version of this post ends every section with a bug. The July version doesn't have the material. That's what stable looks like. It's also what boring looks like. Seven months in, I've decided they're the same thing.", "url": "https://wpnews.pro/news/pilum-from-launch-to-production-ready-in-3-months", "canonical_source": "https://dev.to/logical_bytes/pilum-from-launch-to-production-ready-in-3-months-3769", "published_at": "2026-07-28 15:44:03+00:00", "updated_at": "2026-07-28 16:03:20.120488+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["SID Technologies", "Pilum", "Cloud Run", "Lambda", "Azure", "Cloudflare Pages", "npm", "Docker Hub"], "alternates": {"html": "https://wpnews.pro/news/pilum-from-launch-to-production-ready-in-3-months", "markdown": "https://wpnews.pro/news/pilum-from-launch-to-production-ready-in-3-months.md", "text": "https://wpnews.pro/news/pilum-from-launch-to-production-ready-in-3-months.txt", "jsonld": "https://wpnews.pro/news/pilum-from-launch-to-production-ready-in-3-months.jsonld"}}