The anatomy of a 47-second preview deploy
Every engineer on a product team wants the same thing: push a branch, get a live URL, share it instantly. Vercel and Netlify proved this is table stakes for frontend teams years ago. But what about the 80% of developers building Rails apps, Django backends, Expo cross-platform apps, or mixed-stack monorepos?
For them, the workflow hasn't changed. They're either maintaining 200+ lines of GitHub Actions YAML that takes 4–8 minutes to spin up a preview, or they're not getting previews at all. We built PreviewDrop because that gap shouldn't exist.
Today, we're walking through how we get from branch push to live URL in under a minute — for any framework nixpacks can detect.
The problem: why preview deploys are stuck in 2015
If you've ever set up a preview environment yourself, you've built a pipeline that looks something like this:
- GitHub webhook fires (30 seconds after you push, if the API is chatty)
-
Checkout + detect stack (30–90 seconds: clone the repo, read
package.json, figure out what you're building) - Install dependencies (1–3 minutes: npm install, pip install, bundle install, depending on your lock file size and cache hits)
- Build the app (1–5 minutes: compile TypeScript, run Django migrations, build Rails assets, depending on your app size)
- Build Docker image (30–120 seconds: layer-by-layer compilation, especially if you're not using BuildKit)
- Push to registry (30–60 seconds: docker push to Docker Hub or a private registry)
- SSH into the server and pull + restart (30–90 seconds: ssh, docker pull, docker stop, docker run, wait for health checks)
- DNS propagation (if you're using Let's Encrypt: 5–30 seconds per challenge)
Total: 4–8 minutes is realistic. And that's if nothing fails.
The bottleneck isn't any one step — it's that every step is sequential, and most of them are cold-start expensive. Dependency installation hasn't changed in fundamentals. Docker layer caching helps, but only on warm redeploys.
Our architecture: parallel + cached + cloud-native
When we set out to build PreviewDrop, we asked: what if we eliminated every sequential handoff?
The key decisions:
-
Detect framework immediately — don't clone and install to figure out what you're building. We look at marker files (
Dockerfile,package.json,Gemfile,pyproject.toml,go.mod) to determine the stack in milliseconds. - Use nixpacks for build formula — nixpacks is a maintained, language-agnostic buildpack system. Instead of writing a Dockerfile per framework, nixpacks generates one optimized for your detected language. For a Next.js app, that's npm install + npm run build. For Rails, bundle install + assets:precompile. You don't choose; nixpacks knows.
-
Docker BuildKit with persistent caches — Docker's BuildKit uses cache mounts, which are shared across all builds on a host. Your
node_moduleslayer, your Python venv, your Ruby bundle — they persist between builds. Cold install → warm install is the difference between 2 minutes and 8 seconds. - No image registry — we don't push to Docker Hub or ECR. The image stays local on the worker. Docker socket is mounted into Traefik, so the instant the container starts, routing is live. No push + pull overhead.
- Traefik for runtime routing — instead of waiting for DNS changes, we use Traefik as a reverse proxy. It watches the Docker socket for new containers with specific labels and routes traffic to them in real-time. A URL is live the instant the container is healthy.
Here's the flow:
webhook → enqueue → detect framework → generate Dockerfile
→ docker build (cache mounts) → start container (labeled for Traefik)
→ Traefik updates routing → health poll passes → "ready"
Where the seconds actually go
Let's trace a real warm redeploy (the common case):
| Step | Time | Why |
|---|---|---|
| Webhook fire + queueing | 0.5s | Synchronous, local |
| Clone (shallow, full history) | 2s | ~50 MB for a typical app repo |
| Detect framework + read nixpacks manifesto | 1s | File I/O, no network |
| Generate Dockerfile | 1s | nixpacks is fast |
docker build (with cache hits) |
15s | BuildKit cache mounts mean node_modules/venv/bundle exist already; only changed files rebuild |
| Container start + health checks | 5s | 3-5 quick pings until the web server responds |
| Traefik routing update | 1s | Docker socket polling, label matching |
| Total | ~25s | ... wait, that's faster than 47 seconds. What's up? |
The honest answer: we're being aggressive with cache assumptions. In practice:
- First deploy of a repo: cold Docker layer cache means npm/pip/bundle install runs fully. That's 1–2 minutes alone.
- Dependency changes (you bumped Rails, added a new Python package): even with BuildKit, re-layering takes 30–60 seconds.
- Large monorepos: cloning can take 10–20 seconds on a smaller host.
- Health poll failures (your app takes 8 seconds to start, we're probing every 2 seconds): adds latency.
Our measured median across real repos and real traffic:
Cold first deploy (Expo web): 2m 15s
Warm redeploy (Rails, no dep changes): 47s
Warm redeploy (Next.js, no dep changes): 32s
Warm redeploy (Django, no dep changes): 54s
Warm redeploy (Go, no dep changes): 28s
The headline number: 47 seconds on a Rails repo, which is our strongest positioning against the "4–8 minute GitHub Actions" baseline.
What we sacrificed: the ceiling
Single-instance worker architecture means we're not multi-tenant by default. The build queue is in-memory (backed by BullMQ + Redis, but not distributed yet). The routing knows about ~14 concurrent containers per mid-size Hetzner host.
That's a real ceiling. If you're a team running 100 PRs a day with 5+ parallel builds, you'll hit it. We're honest about this: PreviewDrop is built for teams of 3–25 engineers, not for monorepo platforms at Google scale.
For mid-market teams that outgrow this, the migration path is real: move to a distributed queue (we use Redis' BullMQ; scale to multi-host), move storage to Postgres, and run workers on a Hetzner cluster or Kubernetes. We're designing for that migration from day one.
The actual tradeoff: caching vs cold-start latency
This is where the magic lives. Every preview environment tool faces a choice:
Option A: Speed (cache everything) — keep dependency caches hot, accept that you can only run ~15 concurrent builds per host, and need auto-scaling to handle traffic spikes.
Option B: Stateless (spawn fresh) — spin up fresh containers every time, accept 2–3 minute deploys, but scale infinitely because you don't need cache affinity.
We chose A: speed. BuildKit cache mounts are the difference. On a 20 Mbps line with a typical Rails Gemfile.lock, a fresh bundle install takes 90 seconds. From cache, it's 3 seconds if nothing changed, 15 seconds if one gem was bumped.
The tradeoff is real: we need to manage host capacity and implement autoscaling. But the user experience is so much better that it's worth it.
Why Docker + Traefik + nixpacks works (and why K8s is overkill)
Every preview-environment tool today asks: "Should we just use Kubernetes?"
Here's our take: Kubernetes is correct for 1,000-container platforms at Google scale. For 10–20 concurrent previews, it's expensive tooling for a simple problem.
Docker socket + Traefik solves the problem for 99% of teams:
- Dynamic routing (no DNS propagation wait)
- Container labeling (no state database)
- Auto-restart (if a container crashes, it's gone and a new one will spawn on the next push)
- Native observability (docker logs, docker stats, Prometheus metrics from the container runtime)
Kubernetes would handle 1,000 concurrent previews. But it would also require you to manage kubelet upgrades, etcd snapshots, CNI plugin debugging, and all-night oncalls when the control plane hiccups. For a startup preview-environment tool, that's death by complexity.
Next: distributed caching and multi-host scaling
The ceiling on single-worker is approaching. The next phase is:
- Shared BuildKit cache — move the cache to a shared volume or Docker buildx distributed cache. Allows multiple workers to share layer caches.
- Multi-worker job distribution — BullMQ already supports this; we just need to test it under production load.
- Postgres for build state — move SQLite to Postgres so state survives worker restarts.
We're not shipping these by default at launch, because they're unnecessary for the first 500 paying teams. But they're on the roadmap and architected from day one.
The one-liner
Push a branch. Get a live URL in under a minute. Any framework. No YAML to maintain.
Because preview environments shouldn't require a platform-engineering background. They should just work.
Originally published at previewdrop.com/blog/anatomy-47-second-deploy
Ready to see it in action? Start your free tier — 2 concurrent previews, no credit card. Or read our docs on how preview builds actually work.
Top comments (0)