I wanted a simpler way to run apps on a VPS, so I built ply

In August, I sat down to put a small app on a $6 droplet. A Next.js frontend, a Redis, that's it. Three hours later the app was running, and I realized almost none of those three hours had anything to do with the app. Docker had to be installed and kept running. There was a Dockerfile I'd copied from another project and didn't fully trust. A compose file. Then I wanted deploys that didn't involve me SSHing in, so I looked at the usual tools for that, and the first one I checked wanted 2 CPUs and 2 GB of RAM for itself before I'd even added my app. My app needed 200 MB.
That's the thing that had been bugging me for years without me quite saying it out loud. The stuff around the app kept getting bigger than the app.
I kept thinking about how this used to work. You had a program. You ran it. It printed to your terminal; you hit Ctrl-C, and it stopped. If you wanted it somewhere else, you copied the file. I didn't want to give up what containers give you, the isolation and the "it runs the same over there", but I wanted running one to feel like running a program again. One binary. No daemon. Same command on my laptop and on the server.
So I built ply. It's a container runtime and package manager for Linux, one static binary, about 5 MB. You describe your app and what it needs in a TOML file, build an image, and run it. The base system and the language runtime aren't baked into your image; they're packages that ply fetches once and shares between everything on the machine.
What it looks like
Here's the smallest real example I have, the one in examples/hello-http. One HTML file, a ten-line Node server, and this:
[package]
name = "hello"
version = "0.1.0"
base = "debian@13"
entrypoint = ["node", "server.js"]
include = ["server.js", "index.html"]
[dependencies]
node = "22"
[ports]
web = 8000
[sources]
default = "https://registry.plybox.sh/ply/{package}"
ply build .
# locked node 22.23.2
# locked debian 13.6.0
# built ./hello-0.1.0-linux-x64.img (4.0 KiB)
ply run --publish 127.0.0.1:8080:8000 hello-0.1.0-linux-x64.img
While you're working on it, you don't even need the build step. ply run . rebuilds if something changed and runs it.
The image is 4 KiB. Not a typo. It has your two files and the lockfile, and that's all it needs, because Debian and Node are separate packages. ply downloads them the first time, checks their hashes, and keeps them in a store. The next app that wants Node 22 gets the same copy. Ten Node apps on one box, one Node.
Deploying is scp and the same command. The other machine notices what it's missing, fetches it, and starts the app. I keep waiting for that to feel like a trick, and it hasn't yet.
packages, not layers
This is the part I actually care about, and the part that took me the longest to get right.
Docker thinks in layers. Every step in a Dockerfile leaves a filesystem diff behind, and your image is the pile. Which is fine until you want to know what's in it, and then you're reading apt-get lines and guessing what they pulled in on the day you ran them.
ply thinks in packages. Node is a dependency with a name and a version; the lockfile says exactly which package satisfied it and what its hash is, and you can read that file. I didn't invent any of this. I took Cargo's manifest and lockfile, Go's minimal version selection because it needs no solver and never surprises you, Nix's idea that a package simply is its content hash, and Homebrew's habit of giving every package its own prefix under /opt so nothing ever fights over a path. Four things other people got right, glued together. No install scripts, no hooks, nothing runs when a package is added.
One consequence I like more every week: builds are deterministic. Same files, same locked dependencies, byte-identical image. Every time.
At runtime, a package is a squashfs file. The kernel mounts it read-only and only ever decompresses the blocks that something actually reads. ply stacks them in dependency order with an overlay, puts a writable layer on top, sets up the namespaces, seccomp, and cgroups, and runs your entrypoint as a child of ply run. Your terminal is its stdout. Ctrl-C stops it. The exit code comes back to your shell. ply ps just reads some state files, because there is no daemon to ask.
Rootless works too, through user namespaces. On Ubuntu 24.04 and newer, there's a one-time sudo ply setup for an AppArmor profile, and the installer tells you when. Rootless apps get their own network, and yes, they can bind port 80 in it.
the numbers
I'll be honest about how this went, because the first version of this benchmark was embarrassing.
ply's original port publishing was a userspace relay. It worked. It also ate three cores at 430k requests per second and landed at 0.62 of Docker's throughput. That's not a runtime, that's a proxy with opinions. I replaced it with an nftables DNAT rule that the ply run parent rewrites whenever the pool of instances changes, which is what Docker does too. After that:
/pingthrough a published port, 64 connections: 726k requests/s for ply, 714k for Docker. ply's own processes used 0.0 CPU seconds during that run.A database read through a published port: 257k against 256k.
Ten minutes pinned at 50k requests/s: both flat, p99 within 0.01 ms of each other. ply's whole runtime sat at 22 MB of resident memory. dockerd sat at 145 to 185.
Turning on egress enforcement (restricting what a container may talk to) cost nothing I could measure.
All of that is one 12-core laptop with the load generator on the same machine, one run per cell, so I treat anything between 0.9 and 1.1 as noise. The harness and every raw result, including the embarrassing ones, are in the repository under bench/.
The rolling restart is the one I'm proudest of. Under 655k requests per second, restarting instances one at a time, zero errors across 39 million requests. It did not start at zero. It started at 131, and it took four separate fixes to get down, two in ply and two in the test app, because it turns out a load balancer can't drain a keep-alive connection on the app's behalf; the app has to do its part. That whole race is written up in the results file if you like that sort of thing. I do.
The $4 droplet
This is the test I wanted to run more than the benchmark.
DigitalOcean's smallest droplet. One vCPU, 512 MB of RAM. I put ply on it, gave it a 2 GB swap file with sudo ply setup --swap 2G, and asked it to run the dashboard, Redis, and a Next.js app built from source on that same machine. The build runs in its own container, capped at about 60% of physical memory and at a lower CPU priority, so the apps that are already serving stay responsive while it works. Cold build, 209 seconds. Incremental, 80. I'd take a slower build over a bigger server any day of the week.
Deploys on that box are a file. Literally: a deployment is a file in /var/lib/ply/deployments/and a timer reconciles the host to whatever the files say. Clone the repo, build it here, roll the new version in one instance at a time behind a health check, and if the new one fails its check, put the old one back. No webhook. No port open for a deploy tool. No server keys sitting in CI. Nothing resident between runs except the 5 MB binary on disk.
I think the self-hosting world spent the last decade rebuilding Heroku on top of Docker, and every layer wanted a bigger machine. Vercel is what you pay when you'd rather not have a server. Coolify is a server that's mostly busy running Coolify. I wanted the third option: a server that's busy running my app.
The same binary does the rest of the boring operations, and they're small because the runtime already owns the process. ply up runs a whole stack from one file, the way compose does, except each member is still a normal ply app with its own manifest. Apps declare parameters and other apps reference them, so you write DATABASE_URL={db.url} and ply fills in the address and the generated password and, as a side effect, knows to wait for the database to be healthy first. I was so tired of copying connection strings between .env files. [scale] in the manifest grows and shrinks the instance count on CPU, memory, network, or a metric your app exposes, and min = 0 lets an app go to sleep entirely; the port stays open, the next connection waits a few hundred milliseconds while an instance starts, and a sleeping app costs about 5 MB. Thirty side projects on one small box is a real configuration now, not a joke. [egress] says where an app may connect. And ply why APP answers the question I always end up asking at 11 pm: what happened to this thing.
Because it's all files and one binary, a coding agent can operate a ply host with the shell it already has. There's a skill for that in the repo. That wasn't a goal at the start. It fell out.
What it isn't
It isn't a Docker replacement, and it isn't a Kubernetes replacement. If you have a fleet and need something to place work across machines, keep Kubernetes. ply stops at one host, on purpose, and I'm not planning to change that.
Docker's ecosystem is enormous, and mine is not. Every README on earth says docker run. You can bring those images over with ply import docker://and I've tested twenty-one of the usual suspects (databases, caches, proxies, language runtimes) with their entrypoints and users intact. But an import is a flattened snapshot, not a composition, and twenty-one is a much smaller number than all of them.
Linux only: x86_64 and arm64. There's an experimental native Apple Silicon backend that boots a tiny VM per instance, and you build it from source today. Windows means WSL2.
Where I think it fits: the app on a VPS. The side project. The internal tool. The team of one to five that doesn't want an ops team. For that, I think the trade is obvious.
It's a beta
It's pre-1.0. The CLI and the image format may still change. There are bugs in it I haven't found, and if you try it you'll probably find one. This week I ran the getting-started page on a fresh droplet and on an arm64 VM, as if I'd never seen ply before, and found five things wrong in the first hour that I'd never have hit on my own laptop. All fixed now, but I know what a fresh pair of eyes is worth.
The direction is right, though. I'm sure of that part.
If you've got an afternoon, take an app you actually run and try to package it. Then tell me where it got awkward, where it stopped, and whether you'd use it again. There's an issue template for exactly that. If it just worked, tell me that too. I need to know both.
Repository: github.com/iluxav/ply
Docs: plybox.sh/docs
Install:
curl -fsSL https://plybox.sh/install.sh | shThe comparison, both directions: plybox.sh/docs/ply-vs-docker