Skip to main content

No More Stringly-Typed Deploys: What pikpik's Four Invariants Say About Coolify, Dokploy, and CapRover Reliability

13 min readDora NodaDora Noda
Share
On this page

Five remote-code-execution CVEs, fixed in a single release, all caused by the same one-line habit: building a shell command out of a string. That was Dokploy's 0.29.13 security release. A pre-alpha project called pikpik looked at how self-hosted PaaS panels drive Docker and wrote down four non-negotiable invariants — no shell-string interpolation, no daemon entourage, no file-mediated routing, no disk-staged pipelines. Audited against production evidence, the verdict is clear: the shell-string ban bites hardest, by an order of magnitude, because it is the only invariant with CVEs attached.

The second half matters more for builders. A git-push PaaS that generates Kubernetes manifests through a typed API meets the same bar by construction rather than by rewrite. No strings are ever interpolated, no config files are ever watched, no daemons are ever shelled out to — because there is no shell in the path between intent and effect. The table below states the whole argument; the rest of this post is the evidence.

#InvariantWhat it bansBites-hardest rankKubernetes-native equivalent
1Zero shellingsh -c with interpolated strings; all Docker ops go through the typed SDK1st — RCE CVEs plus everyday breakageTyped API clients; the API server validates schema, no shell in the path
2Single unified runtimeExternal Redis, Postgres, and worker daemons for the control plane2nd — outage blast radiusOne operator binary; etcd is already there
3Dynamic API ingressRouting config written to files and picked up by watchers or reloads3rd — stale routes, reload racesIngress/Gateway objects reconciled by controllers
4Pure streaming pipelinesBackups and builds staged through /tmp4th — disk-full failures on small VPS disksChunked API streaming; snapshots instead of dumps-to-disk

One honest note before the audit: pikpik itself is pre-alpha, has zero stars, and had not been pushed in six weeks at the time of writing. Its architecture audit was written by its own authors. So this post does not review pikpik the product — there is nothing deployed to review.

It audits pikpik the critique against the public record of the three panels it names: Coolify (roughly 63,000 stars), Dokploy (roughly 38,000 stars), and CapRover (roughly 15,000 stars). The charge sheet turns out to be accurate. We have previously compared these panels on second-machine cost and control-plane pricing; this post is about the mechanism underneath those comparisons.

Invariant 1: zero shelling, the one with CVEs attached​

Pikpik's first invariant is absolute: the control plane never invokes sh -c or interpolates strings into shell commands, and every container operation goes through the typed Docker Engine SDK over the socket. Its audit of Dokploy counts more than 80 child_process.exec call sites in Dokploy's server code. That number comes from a partisan source, but the CVE record backs the substance. Dokploy's 0.29.13 release fixed a cluster of shell-interpolation vulnerabilities, each one a user-controlled value concatenated into a command string:

  • CVE-2026-72870: the application-controlled dockerImage value interpolated directly into a docker pull shell command, so a crafted image name executed arbitrary OS commands as the server process.
  • CVE-2026-72878: the backup and restore pipeline interpolating user-controlled database fields into bash -c and sh -c strings, with injected commands executing on the host machine, not just inside a container.
  • CVE-2026-72738 (CVSS 9.9): a backup-search parameter passed through to an rclone lsjson command, giving a user with mere backup-read permission arbitrary command execution on the host.
  • CVE-2026-72901 (CVSS 9.9): a volumeName field interpolated without quoting into volume-backup commands, exploitable by a low-privilege member, with Docker socket access making it host/root-equivalent.
  • CVE-2026-93425: a user-controlled repository path passed unquoted into a shell command, executing as root in the container.

Note the shape of every one of these: a name the user legitimately controls — an image, a database, a volume, a path — becomes code the moment it crosses a string boundary. This is exactly the "adversarial app names" failure the invariant predicts. And below the CVE line sits the everyday breakage the same habit produces: a generated Postgres backup script missing a semicolon between two statements, a backup name with special characters failing only on remote servers, a web-server backup failing because zip returned exit code 18 on an unreadable socket file and the execAsync wrapper treats every nonzero exit the same. None of these would compile, let alone run, against a typed API.

Coolify's evidence looks different but points the same way. Coolify drives its targets over SSH — even localhost is treated as a remote, with commands assembled as strings and executed through its remote-command layer. String pipelines have physical limits, and Coolify hit one in public: deploying large Compose files broke because the file was base64-encoded into a single SSH command argument, and Linux caps a single argument's size. The fix streams content over SSH stdin instead.

Quoting bugs get fixed piecemeal too — the v4.4.0 release notes include "quick adding tags with special characters, such as apostrophes, works." Each fix is correct and each fix is whack-a-mole: a string boundary has infinitely many adversarial inputs, and the panel must enumerate them one release at a time.

CapRover, to be fair, has the smallest shell surface of the three and little of its pain is interpolation-shaped. Its evidence belongs in the routing section below, which is where file-mediated mechanisms bite it. The Invariant 1 verdict stands regardless: shell-string deploys are the only failure class in this audit with critical CVEs, root-equivalent impact, and low-privilege exploitability attached. Everything else is an outage; this one is a takeover.

Invariant 2: one binary, no daemon entourage​

Pikpik's second invariant compiles the whole control plane — REST gateway, task queue, telemetry collector, cron scheduler — into a single static Go binary with an embedded SQLite database, and requires zero external daemons. The contrast it draws is Dokploy's multi-container stack: the Node server, Postgres, Redis with BullMQ, Traefik, a schedule worker, and a monitoring container. Coolify's footprint rhymes: a Laravel app with Postgres, Redis, and queue workers beside it. CapRover is leaner but still a small distributed system: the Captain service, nginx, and a Swarm behind them.

The failure mode this buys is blast radius. Every daemon in the panel's own stack can take down the control plane without touching a single tenant container: Redis evicts the queue's keys under memory pressure and scheduled jobs silently stop, Postgres fills its disk and the dashboard goes read-only, an upgrade migrates the schema while the old worker image is still running.

None of this is specific to these panels — it is the ordinary tax of running a distributed system to manage what is, on most self-hosted boxes, a single machine's Docker daemon.

Single-binary-plus-SQLite is not magic either. SQLite has one writer, which caps write concurrency, and embedding the queue in-process means a control-plane restart drops in-flight jobs unless they are journaled. But the failure modes shrink to one process and one file, both trivially snapshotted and restarted.

The operator's 3am question changes from "which of my six containers is lying to the other five" to "is the binary up and is the file intact." That is why this invariant ranks second: it bounds every outage to something one person can reason about while tired.

Invariant 3: routes via API, not files on disk​

Pikpik's third invariant pushes routing mutations straight into Caddy's dynamic Admin API in memory — claimed under 15 ms — and never writes a config file the proxy must notice. Dokploy, in this telling, does the opposite: it writes Traefik dynamic YAML files to disk and waits for the file provider's watcher to pick them up, a loop pikpik's audit prices at 50–500 ms per change. Treat both numbers as partisan measurements of a real structural difference: a file-mediated loop has a poll interval, a partial-write window, and a watcher that can miss events, while an API mutation either applies or returns an error.

CapRover is the honest middle ground that proves the file model's costs even when it is carefully built. It generates nginx config files and reloads the proxy under a lock — its own logs show the "Locking NGINX configuration reloading" dance around every change, which is exactly the reload race between panel and daemon made visible as machinery. And the file model still bites: a faulty nginx config can fail silently until the container reboots, and the official troubleshooting docs include a "you broke the admin UI with an nginx change" recovery flow with a dedicated fixer script. When your docs ship a recovery tool for the config mechanism itself, the mechanism is telling you something.

Dokploy's Traefik loop has its own panel-meets-daemon races at the seams. Its access-log cleanup shells out to a hardcoded docker exec dokploy-traefik kill -USR1 1 — a container name baked into a string — which breaks outright on Swarm, where no task carries that name, and the surrounding rotation chain in the same handler is a tail-and-mv shell pipeline with zombie-process failure modes. Invariants 1 and 3 failing together: a string-named daemon manipulated through a string-built command. API-driven ingress ranks third because stale routes and failed reloads are user-visible but usually self-healing — takeovers and control-plane outages are not.

Invariant 4: streams, not /tmp staging​

The fourth invariant bounds backup and build pipelines to streaming dataflow — pipe to gzip to S3 multipart upload, under 32 MB of RAM, zero bytes staged on disk. Dokploy's backup path is the counterexample: dump the database to /tmp, then shell out to an external rclone binary to push the file to object storage.

The failure mode is the small disk. Self-hosted panels disproportionately run on 20–40 GB VPS disks shared with Docker's own image and volume storage, and a dump that fits comfortably in S3 does not necessarily fit in the free space beside it. A backup that fails at 95% of disk is the worst kind of failure: the thing you built for the emergency is what has the emergency.

This invariant ranks last not because the failure is rare — disk-full backup failures are common — but because the blast radius is narrow and the fix is local. It bites the backup path, not the deploy path; it corrupts no state, it just fails to produce an artifact. Streaming is strictly better engineering, and the memory-ceiling discipline is admirable, but nobody has ever lost a weekend to /tmp staging the way they have lost one to a shell-injection CVE or a dead Redis. Rank it fourth, fix it anyway.

The ranking, with who feels each one first​

Stated plainly, in bites-hardest order:

  1. Zero shelling. Felt first by everyone at once: tenants whose names become code, operators who inherit CVEs, security reviewers who must audit every interpolation site. The only invariant whose violation is a takeover rather than an outage.
  2. Single unified runtime. Felt first by the operator on call, during upgrades and at 3am. Every daemon in the panel's own stack is a page that has nothing to do with tenant workloads.
  3. Dynamic API ingress. Felt first by end users during deploys and scale events, as stale routes and failed reloads. Real, visible, usually self-healing.
  4. Pure streaming pipelines. Felt first by whoever owns backups on a small disk. Common, narrow, and the easiest to fix in isolation.

One sensitivity note: on a strictly single-tenant panel where the owner types every value, Invariant 1 drops to an everyday-correctness concern and Invariant 2 tops the list. But panels grow tenants, tenants grow adversaries, and Dokploy's CVE cluster shows what "we will sanitize every interpolation site" costs in practice. The order above assumes the panel you will have in two years, not the one you installed yesterday.

What typed Kubernetes manifests inherit for free​

Here is the payoff for builders. Each invariant has a Kubernetes-native equivalent that a git-push PaaS gets without writing reliability engineering from scratch — not because Kubernetes is simple, but because its control loop never had a shell in it:

InvariantThe Kubernetes-native equivalentWhy it holds by construction
Zero shellingControllers build typed objects with client libraries; the API server validates schema before persistingThere is no command string to interpolate into — a malformed value is a rejected object, not executed code
Single runtimeOne operator binary; etcd ships with the clusterThe panel's bookkeeping rides storage the platform already runs and backs up
API ingressRoutes are Ingress or Gateway API objects; controllers reconcile themThe panel writes desired state, never proxy config files; there is nothing to watch or reload
StreamingLogs, exec, and watches stream chunked over the API; backups are VolumeSnapshots or direct-to-object copiesThe primitive is a stream with backpressure, not a file that must fit on disk

This is the bet Bex.co makes: a deploy pipeline that turns a git push into typed manifests applied through the API inherits pikpik's whole bar — no stringly-typed deploys, no file-watcher routing, no daemon entourage — as a property of the substrate rather than as four disciplines every contributor must remember. The invariants stop being rules and start being the only way the code can express itself.

The honest caveats​

Three, and they cut both ways. First, pikpik is pre-alpha with no production users, and its audit was written by its own authors — one of whom appears to be an agent ("Teamwork Chaos & Adversarial Engine" is credited as auditor). Treat its latency numbers and parity claims as design goals with tests, not field measurements. The critique is load-bearing here, not the code.

Second, the replacements have their own sharp edges. Caddy's Admin API on localhost is fine until something else on the box can reach it — an unauthenticated mutation endpoint is its own CVE waiting on a multi-tenant machine. SQLite's single writer is a real ceiling, and typed SDK calls with wrong values misconfigure just as confidently as shell strings. "No shell" removes an entire bug class; it does not remove bugs.

Third, the panels are moving. Dokploy fixed the CVE cluster in 0.29.13, Coolify replaced its base64-over-SSH hack with stdin streaming, and every one of these projects accepts that the string boundary is where the bugs live. Pikpik's contribution is not discovering the failure class — it is refusing to negotiate with it, in four sentences a reviewer can check in every pull request.

Deterministic deploys are the whole game. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, driven through typed APIs with no shell-string interpolation anywhere in the path. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex