If you build your own PaaS, you don't need SaaS
No seriously, you too can be a micro-Vercel
My canon event was opening a dashboard, seeing a $50/mo bill for a pet project, and asking what I was paying for. A CDN? A build queue? S3 repackaged with a nice UI? I'm an engineer. Could I just build this myself?
That question became my side project, pdploy, now Deployher. The code is open source on GitHub, and the hosted version runs the same stack you can put on your own box. I'm not trying to beat Railway, Dokploy, or Coolify in a feature matrix with this project. User 0 was always going to be me. Since I launch small stateful projects constantly, I wanted a deploy button I controlled.
The enshittification of everything
Storytime.
In the fall of 2022, Heroku killed their free tier. Salesforce's official line was fraud and abuse. TechCrunch covered it. The Dev Center changelog is blunt: as of November 28, 2022, free dynos and hobby-dev Postgres/Redis were gone.
After a decade of "just git push and it works bro" Salesforce announced they were "sunsetting free product plans" starting November 28th, 2022. Dynos from $7/month, Postgres at $9.
Heroku had been the easiest way for many developers to publish their first app. A year later, Replit tightened its limits too.
Their announcement pointed to "more powerful infrastructure" "crypto mining abuse" and users burning "thousands of dollars of bandwidth" The free plan survived, but barely. (Although they did outline some fair reasons.)
I don't want to be too dramatic here. These are businesses; obviously they need to make money. Servers cost money. Abuse costs money. GPUs cost stupid money.
But “users ruined the free tier” lets the companies off the hook. Subsidized growth is deliberate: a company offers a generous tier, attracts users, reports the growth, and postpones the question of who will pay for it.
Then later shows up.
Suddenly, the same users who helped guide a product to prominence are recast as the problem. The usage that once looked like "we're a16z bound" now looks like abuse. The story quietly shifts from “look at how much DevRel we do” to “people are burning thousands in bandwidth, daily.”
And yes, some people are actually abusing it. Mining crypto on free accounts is insane behavior. Spinning up ten accounts to dodge limits is obviously lame. I’m not defending that. But most users are not running some evil bandwidth cartel. They are just using the product under the limits the company itself created.
That is the part I find annoying.
The user did not invent the business model. The user did not set the limits. The user did not decide to subsidize usage for growth, community, market share, or investor screenshots. The company did.
Cory Doctorow calls this enshittification: a platform first favors users to attract them, then favors business customers, then extracts as much as it can from both. Heroku's free tier fits that pattern. So does Netflix moving from "love is sharing a password" to restricting password sharing.
The market is also less forgiving of SaaS growth than it was a few years ago.
Vercel and Netlify haven't gone full Heroku yet, but the writing's on the wall. Per seat, per function, per revalidation, per bandwidth spike on launch day. People rent compute at a markup and call it "developer experience." Fly.io wrote a whole post about why bandwidth billing is painful. For most side projects it doesn't have to be that way.
PaaS 101
For the kind of projects I deploy, a PaaS such as Vercel, Netlify, or Heroku can be reduced to a handful of components. Running those components yourself does not reproduce their global infrastructure, but it covers the deploy path I needed.
- Web server → Handle deploy requests.
- Build queue → Ensure builds happen in order.
- Build worker → Isolate builds in containers.
- Object storage → Store build artifacts (HTML, JS, etc)
- Preview server → Serve the built assets on a unique subdomain.
- Authentication → Secure access to deploys and persistent sessions.
- Database → Track users, projects, and deploys.
Dashboards, GitHub integrations, log streaming, and graphs sit on top of that path. Deployher keeps the queue and object storage behind replaceable interfaces; Redis Streams and Garage are the current choices, not requirements baked into the model.
Choosing violence in a tech stack (Bun)
I went all-in on Bun proudly. Not as a package manager. As the runtime. Server, workers, tests, the lot. Bun 1.3 was the tipping point for me: built-in Redis client, better HTTP ergonomics, native S3. Then Anthropic acquired Bun in late 2025. Jarred's post is worth reading if you care about where JS runtimes are heading. I didn't pick Bun because Anthropic might win the agent wars. I picked it because import { S3Client } from "bun" beats dragging in half of npm for artifact uploads.
The S3 client is the part I still brag about. Deployher stores build output in Garage, an AGPL-licensed S3-compatible store from the Deuxfleurs collective. They've run it in production since the early 2020s, and it targets small deployments like mine. In Node I'd reach for @aws-sdk/client-s3. But in Bun:
1import { S3Client } from "bun";2
3const client = new S3Client({4 endpoint: config.s3.endpoint,5 region: config.s3.region,6 bucket: config.s3.bucket,7 accessKeyId: config.s3.accessKeyId,8 secretAccessKey: config.s3.secretAccessKey,9 virtualHostedStyle: false10});11
12await client.file(`${artifactPrefix}/index.html`).write(data, { type: "text/html" });
That's the whole vibe. No command objects. No six subpackages. Redis is the same story: raw XADD / XREADGROUP over a connection, plus EVAL for Lua when you need atomic slot accounting. I benchmarked it against ioredis once on our stream workload and Bun was faster. Doesn't matter at our scale. Still satisfying.
The dependency tree is not the six-package fairy tale from the prototype anymore. The dashboard grew into a real app: Vite, shadcn/ui, TanStack Query, React Router. The build pipeline still leans on Bun for I/O. See package.json in the repo if you want the full list. Bigger than a demo, still not node_modules as a lifestyle.
Under the hood (without turning this into README cosplay)
I'm not going to paste half the repo here. If you want file-level detail, docs/SETUP.md and docs/DEPLOYMENT.md exist for a reason. What follows is the shape of the thing, and why I made annoying choices.
Boot and config
src/index.ts starts Bun.serve, optionally builds the Vite client in production (SKIP_CLIENT_BUILD=1 in Docker), pings S3, schedules queue stall alerts, and nags the preview-runner to rewarm server previews after a restart. Dev mode skips the client build and assumes you're on bun run dev:vite for HMR.
Config is layered: config/default.toml, optional config/local.toml, then env vars for secrets. Split-domain production needs DEPLOYHER_PRIMARY_DOMAIN, per-hostname env vars, and BETTER_AUTH_URL on the API origin. Preview URLs slug project names; individual deploys get short base-36 IDs so {shortId}.deployher.com doesn't look like a UUID got loose.
Router
Still no Express. Still hand-rolled in src/router.ts. ~580 lines now because GA added CSRF, CORS, CLI bearer auth, observability APIs, and a pile of /api/ui/* routes for the SPA.
The rule that saved my sanity: check the Host header before path matching. Preview traffic hits {shortId}.yourdomain first. Caddy on the edge container handles host routing in production; Bun still serves previews and API. Dashboard routes like /projects/:id return the Vite shell from dist/client/index.html; React fetches JSON separately. The early dual HTML/JSON page renderer is dead. Good riddance.
Database
Drizzle on Postgres. Schema in src/db/schema.ts. Better Auth tables, plus projects, env vars, deployments, deployment events, preview traffic, alert rules. The deployments row is where the drama lives: build strategy, serve strategy, artifact prefix, runtime image digest, preview resolution metadata, heartbeats, retry counts. currentDeploymentId on projects is how "promote this deploy" works. Not magic. Just a pointer you can swing backward.
Migrations in /drizzle/, applied on app-api startup when RUN_MIGRATIONS=1.
The queue
Deploy jobs go through Redis Streams consumer groups, not BullMQ. BullMQ is fine. I didn't need 15k lines of framework for six commands: XADD, XREADGROUP, XACK, XAUTOCLAIM, and friends. Implementation lives in src/queue.ts.
The harder part was per-account concurrency. Without it, one person can queue fifty builds while everyone else waits. Deployher uses a Lua script through Redis EVAL to grant slots in a set keyed by user ID. Each slot has a TTL so crashed workers don't hold capacity forever. When no slot is available, the worker re-enqueues the job with a fresh stream ID and acknowledges the old message. Leaving it in the pending list caused infinite retry loops. The deferral logic lives in src/queue.ts.
Workers scale with docker compose up -d --scale deployment-worker=4. Crash recovery is XAUTOCLAIM: steal idle jobs from dead consumers.
The build worker
src/workers/buildWorker.ts is ~2,000 lines and the file I open when something catches fire at 1am. Yes, I know that's too long. No, I'm not splitting it before the next thing breaks.
Flow in plain English: dequeue job, mark building, heartbeat, download repo zipball from GitHub's API (OAuth token from Better Auth), merge env vars, detect strategy, run install/build inside ephemeral Docker containers via dockerode, upload artifacts to Garage, push a runtime image to Nexus (or your registry), update the row, stream logs over Redis pub/sub to SSE.
Strategies: Node (npm/pnpm/yarn/bun, including Next.js server mode when .next/ exists), Python (pip/uv/mkdocs), static uploads, Dockerfile-first server builds. Build containers get labels like io.deployher.build=true so cleanup is a filter, not archaeology. Workdirs live under /tmp/deployher-builds on the host, bind-mounted into workers so the daemon sees the same paths.
app-api never mounts the Docker socket. Only deployment-worker and preview-runner do. Docker socket access is effectively root access on the host, so the API process should not have it.
Storage and bootstrap
Garage runs in Compose (dxflrs/garage:v2.2.0). Artifacts bucket + avatar bucket. Multipart uploads for big build output. Incremental log reads via byte offsets so the log viewer doesn't re-fetch megabytes of stdout every poll.
The old bash bootstrap script is gone. The deployher CLI (bun run deployher bootstrap on a VPS, deployher start on a laptop with demo seed) brings up Postgres, Redis, Garage, Nexus, runs migrations in oven/bun without requiring Bun on the host, syncs base images, starts edge/app-api/marketing/workers/runner. Production env rollouts: deployher ops storage-env-deploy.
Previews
Three ways to hit a deploy:
{shortId}.deployher.com(wildcard DNS)/d/{shortId}/…path fallback/preview/{deploymentId}/…for server runtimes via preview-runner
Static files serve from S3 through Bun with sensible cache headers (immutable hashed assets, no-cache HTML). Server apps run in bounded containers the runner pulls by registry digest. Next.js, Bun APIs, Dockerfile deploys. The S3 tarball path still exists for older rows but new deploys push to the registry.
UI and auth
Three surfaces:
apps/marketing: Astro landing at the apexdist/client: Vite React dashboardsrc/: Bun API
The prototype was streaming SSR with almost no client JS. Cute. GA needed env editors, observability charts, promote buttons, drag-and-drop static uploads. I caved. Bun owns API and previews; React owns the app UI. Fair trade.
Better Auth handles GitHub OAuth, email/password, sessions, and device authorization for deployher login.
Things I learned (the hard way)
Docker socket access is a privilege, not a personality trait
Everyone warns you about "Docker-in-Docker." What we actually do is mount /var/run/docker.sock into the worker and talk to the host daemon through dockerode. Ephemeral build containers, bind-mounted workdirs, label-based cleanup. The gotcha that actually got me: the path on the host has to match what the daemon sees. /tmp/deployher-builds on the worker is /tmp/deployher-builds on the host, full stop. Docker's own docs are very clear that this is basically root. That's why app-api doesn't get the socket. Only workers and preview-runner.
Redis Streams beat a queue framework for this job
If your semantics are "FIFO, consumer groups, crash recovery," Redis Streams is enough. BullMQ is great software. I didn't need a framework-sized dependency for XADD, XREADGROUP, XACK, and XAUTOCLAIM. The interesting bit was never the stream. It was not letting one user hog the build farm.
Bun's S3 client is still the best "why Bun" answer
No @aws-sdk/* dependency tree. No credential provider chain cosplay. import { S3Client } from "bun" and upload artifacts. Multipart writer for big files. Byte-offset log reads. Boring infrastructure that stays boring.
I was wrong about zero client JS
The early prototype used streaming SSR with almost no hydration. Romantic. Also wrong for GA. Env editors, observability charts, promote buttons, drag-and-drop static uploads: that's a real frontend. Vite + React on the client, Bun for API and previews. Fine.
Custom routers don't explode until they do, then they don't
Subdomain extraction + split-host edge routing + preview fallbacks: ~580 lines in src/router.ts and I can still trace a request without seventeen middleware layers. Would I recommend this for a CRUD app? No. For a deploy platform where Host is load-bearing? Yeah, actually.
Preview-runner separation saved my sleep
Build workers push images. preview-runner pulls and runs them with TTL and memory caps. Crashy preview containers don't take down auth or the dashboard. Worth the extra Compose service.
What's next
Deployher is GA, but not done. Still on the list:
- GitHub push auto-deploy: no built-in
pushwebhook yet. Queue deploys from your own CI via the API ordeployher login+ remote deploy for now. - Build caching: Docker layer cache and
node_modulesreuse between builds on the same worker. Right now every build is a fresh container with fresh pain. - Multi-node Garage: single-node today. Garage supports replication when we need it.
- Horizontal app scaling: multiple
app-apireplicas behind shared Redis/Postgres. Preview-runner already isolates runtime load.
Shipped since the early draft of this post: server previews (Next.js, Bun APIs, Dockerfile deploys), Caddy edge routing, split-domain production, promote/rollback via currentDeploymentId, observability webhooks, static drag-and-drop uploads, CLI device auth, avatar storage, and the Astro marketing site.
The punchline
The smallest useful deployment platform needs a build queue, artifact storage, and a server for the result. Commercial platforms add scale, regional infrastructure, integrations, and support; my projects did not need all of that.
Vercel wraps that in a global CDN and edge functions. Netlify wraps it in forms and identity. Railway wraps it in a nice dashboard and a credit card. They're good products. The core you're renting is smaller than the line items suggest.
Deployher runs on a cheap VPS (Hetzner for me; deployher.com runs the same stack). Node, Python, static uploads, Next.js server mode, Dockerfile-first deploys, GitHub OAuth, env vars, live build logs, subdomain previews, container-isolated builds. It's not Vercel. It's not trying to be. It's the thing I built because I got tired of paying rent on my own deploy button.
Building it forced me to understand each step: download the zipball, run the build container, upload artifacts, push the runtime image, proxy the preview, and route the subdomain. SaaS pricing made more sense once I knew which parts I was paying someone else to operate.
PaaS isn't so bad. You just have to decide whether you're the customer or the builder. I'm both, apparently.