How to Scale Dev Environments to Zero When Idle
Preview environments burn money overnight. The wake-on-request proxy pattern with connection buffering and cold start optimization cuts idle cost to $0.
Scale-to-zero stops containers when they receive no traffic and restarts them automatically on the next HTTP request — so dev, staging, and preview environments stop costing compute while nobody uses them, without changing how developers access them. How much you save depends on how much of the day each environment sits idle; the worked example below puts a typical preview fleet at well over half.
TL;DR: Scale-to-zero stops idle dev and staging containers after a configurable timeout and restarts them on the next request. Preview environments are the clearest case: they're opened for minutes during review and sit idle the rest of the day. The price is a cold start on the first request after idle, which is fine for non-production and wrong for production.
How Much Do Always-On Preview Environments Actually Cost?
Preview environments are some of the worst offenders for idle spend. They run around the clock despite being used for minutes per day during code review.
Here's a worked model for a typical team (the assumptions are ours — plug in your own):
- 10 open PRs with preview environments, each running a 512 MB container
- Cost per container: $5–10/mo on most cloud providers
- Active usage: maybe 2 hours per day during code review
- Idle time: 22 hours per day, or 91% of the container's lifetime
That's $50–100/mo just for PR previews, and with 22 of every 24 hours idle, scale-to-zero would remove most of it — the remaining cost is the 2 active hours plus the idle timeout after each use. Add staging, QA, and demo environments and a mid-size team easily runs 20–30 non-production environments.
The Compound Effect Across Environments
Cloud providers charge by the hour. A container that serves zero requests at 3 AM costs the same as one handling 1,000 requests per second at 3 PM. There's no built-in incentive for providers to help you stop paying for idle resources.
What Is Scale-to-Zero and How Does It Work?
Scale-to-zero is a resource management pattern where containers stop completely when they receive no traffic for a defined period. AWS Lambda popularised scale-to-zero for functions. The same principle applies to containers.
The lifecycle works in four stages:
- Active phase — the container runs normally, serving requests
- Idle detection — a timer tracks seconds since the last request
- Sleep phase — after the idle timeout expires (say 5 minutes), the container stops
- Wake phase — the next incoming request triggers a cold start, booting the container
Subsequent requests hit the running container normally. It only sleeps again after another idle timeout passes with zero traffic.
Why Not Just Use Serverless?
Serverless (Lambda, Cloud Run) does scale-to-zero natively. But preview environments aren't stateless functions — they're full applications with databases, file systems, and long-running processes. Containerized scale-to-zero gives you the cost savings of serverless with the flexibility of a full runtime.
How Does the Wake-on-Request Proxy Pattern Work?
The wake-on-request pattern uses a reverse proxy to intercept traffic, check container state, and manage the sleep/wake lifecycle. On Kubernetes, Knative provides the same pattern. But you don't need Kubernetes — it works with plain Docker and a lightweight proxy.
Here's the full request flow:
┌─────────────────┐
HTTP Request ──> │ Reverse Proxy │
│ (Nginx/Pingora)│
└────────┬────────┘
│
┌────────v────────┐
│ Container alive? │
└───┬─────────┬───┘
│ │
YES NO
│ │
│ ┌────v────────────┐
│ │ Hold request │
│ │ Start container │
│ │ Wait health check│
│ └────┬─────────────┘
│ │
┌───v─────────v───┐
│ Forward request │
│ Update last_activity │
└────────────────┘
Background sweeper (every 30s):
- Check last_activity for each container
- If idle > timeout: docker stop
The Critical Details
Three things make or break this pattern:
Request buffering. When a container is sleeping, the proxy must hold the incoming request in memory without dropping it. The client sees a slow response, not an error. If the container takes too long to wake, the proxy should return a 503 with a retry header — not hang indefinitely.
Health check timing. Don't forward the buffered request the instant docker start returns. The container process may be running but the application inside isn't ready. Poll a health endpoint (e.g., /healthz) until it returns 200, then forward.
Last-activity tracking. Every proxied request updates a timestamp. A background goroutine or thread sweeps all containers every 30 seconds, stopping any that exceeded their idle timeout. This is cheaper than per-container timers and uses roughly 100 KB of memory regardless of container count.
Can You Build Scale-to-Zero with Docker and Nginx?
Yes. A DIY solution needs three components: a proxy (Nginx or Traefik), a daemon that manages container lifecycle, and a way for the proxy to communicate with the daemon. The Docker API handles 250+ container operations per second on modest hardware, so the start/stop overhead is negligible.
Here's a minimal Python daemon that implements the core logic:
#!/usr/bin/env python3
"""Minimal scale-to-zero daemon for Docker containers."""
import time
import threading
import docker
from flask import Flask, jsonify
client = docker.from_env()
app = Flask(__name__)
# Track last request time per container
last_activity: dict[str, float] = {}
IDLE_TIMEOUT = 300 # 5 minutes
WAKE_TIMEOUT = 30 # Max seconds to wait for container
SWEEP_INTERVAL = 30
def get_container(name: str):
try:
return client.containers.get(name)
except docker.errors.NotFound:
return None
def is_healthy(container) -> bool:
"""Check if container is running and healthy."""
container.reload()
if container.status != "running":
return False
health = container.attrs.get("State", {}).get("Health")
if health is None:
return True # No healthcheck defined, assume ready
return health.get("Status") == "healthy"
def wake_container(name: str) -> bool:
"""Start a stopped container and wait for health."""
container = get_container(name)
if not container:
return False
if container.status == "running":
last_activity[name] = time.time()
return True
container.start()
deadline = time.time() + WAKE_TIMEOUT
while time.time() < deadline:
if is_healthy(container):
last_activity[name] = time.time()
return True
time.sleep(0.5)
return False
@app.route("/wake/<name>", methods=["POST"])
def handle_wake(name: str):
"""Nginx calls this via auth_request or proxy_pass."""
if wake_container(name):
return jsonify({"status": "ready"}), 200
return jsonify({"status": "timeout"}), 503
@app.route("/activity/<name>", methods=["POST"])
def handle_activity(name: str):
"""Called on every proxied request to update timestamp."""
last_activity[name] = time.time()
return "", 204
def idle_sweeper():
"""Background thread that stops idle containers."""
while True:
time.sleep(SWEEP_INTERVAL)
now = time.time()
for name, last_seen in list(last_activity.items()):
if now - last_seen > IDLE_TIMEOUT:
container = get_container(name)
if container and container.status == "running":
print(f"Stopping idle container: {name}")
container.stop(timeout=10)
del last_activity[name]
if __name__ == "__main__":
sweeper = threading.Thread(target=idle_sweeper, daemon=True)
sweeper.start()
app.run(host="127.0.0.1", port=9090)
Nginx Configuration for Wake-on-Request
The Nginx config uses auth_request to call the daemon before proxying:
server {
listen 80;
server_name ~^(?<container_name>.+)\.preview\.example\.com$;
# Wake the container before forwarding
auth_request /internal/wake;
location /internal/wake {
internal;
proxy_pass http://127.0.0.1:9090/wake/$container_name;
proxy_read_timeout 35s; # Slightly above WAKE_TIMEOUT
}
location / {
proxy_pass http://$container_name:3000;
proxy_set_header Host $host;
# Track activity asynchronously
post_action @track_activity;
}
location @track_activity {
internal;
proxy_pass http://127.0.0.1:9090/activity/$container_name;
}
}
The Tricky Parts
This basic setup works, but it has gaps you'll hit in production:
WebSocket handling. The auth_request fires once on connection upgrade, but WebSocket connections stay open. You need to track WebSocket connection count separately and only consider a container idle when both HTTP requests and WebSocket connections are zero.
Concurrent wake requests. If 10 requests arrive simultaneously for a sleeping container, all 10 trigger a wake. Add a per-container lock so only the first request starts the container; the rest wait on the lock.
Container networking. Stopped containers lose their DNS entry in Docker's internal network. You may need to use docker pause/docker unpause instead if you rely on Docker DNS.
The biggest source of bugs in DIY scale-to-zero isn't the wake logic — it's the concurrent request handling. Without proper locking, you get race conditions where two threads both see "container stopped" and both call docker start, causing errors.
Platform Comparison: Scale-to-Zero for Dev Environments
| Feature | Temps | Vercel | Render |
|---|---|---|---|
| Preview env scale-to-zero | Yes (on_demand: true) | N/A — no persistent process to scale down | Free tier: auto-sleeps after 15 min. Paid tiers: always-on |
| Idle timeout (configurable) | 60s–86400s | N/A | N/A |
| Wake timeout (configurable) | 5s–120s | N/A | ~30–60s cold start (free tier only) |
| Per-environment config | Yes | N/A | No |
| Project-level default | Yes (preview_envs_on_demand) | N/A | No |
| Self-hosted | Yes, free (MIT / Apache 2.0) | No | No |
| Proxy layer | Pingora (Cloudflare-built Rust) | Proprietary | Proprietary |
Vercel isn't "always-on" in the way this table's title implies — it doesn't have a scale-to-zero toggle because there's no persistent container to toggle. Preview deployments run as serverless functions that spin up per request and disappear, which comes with its own tradeoffs: no long-running processes, no WebSockets, and cold starts on every cold invocation. Render's free tier does auto-sleep after 15 minutes of inactivity — a real form of scale-to-zero — but that's a free-tier limitation, not a configurable feature, and most teams running real PR-preview workflows are on a paid Render plan, which runs continuously with no idle stop.
What Temps actually offers that neither has: scale-to-zero as a configurable feature on a persistent container — so a preview environment can run a real stateful process (not just serverless functions) while still costing nothing when idle, with the idle and wake timeouts set per-project or per-environment rather than inherited from a plan tier.
How Does Temps Implement Scale-to-Zero?
Temps calls this feature on-demand environments. Set on_demand: true in your environment configuration and containers automatically stop after idle_timeout_seconds of no traffic (default: 300 seconds, range: 60–86400). They wake automatically when the next HTTP request arrives, within wake_timeout_seconds (default: 30 seconds, range: 5–120).
Configuration
Three parameters control on-demand behavior per environment:
{
"on_demand": true,
"idle_timeout_seconds": 300,
"wake_timeout_seconds": 30
}
on_demand— enables scale-to-zero for this environmentidle_timeout_seconds— seconds of inactivity before containers stop (60–86400)wake_timeout_seconds— max seconds to wait for wake on the next request (5–120)
Project-Level Default for Preview Environments
Set preview_envs_on_demand: true on a project and every newly auto-created preview environment inherits on-demand mode automatically. You can also set project-level defaults for preview_envs_idle_timeout_seconds (default: 300) and preview_envs_wake_timeout_seconds (default: 30). Existing environments are not affected — only previews created after enabling the flag.
This is the key operational win: instead of configuring each preview environment individually, you opt in once at the project level and every future PR preview scales to zero by default.
How the Proxy Handles Wake
Temps uses Pingora (Cloudflare's open-source Rust proxy, the same proxy layer that serves Cloudflare's own traffic) as its reverse proxy. When a request arrives for a sleeping on-demand environment:
- Pingora checks the environment's state
- If sleeping, it buffers the incoming request
- The control plane starts the container via the Docker API
- The proxy waits until the container is ready to accept requests (up to the wake timeout; after that the caller gets a 503)
- The buffered request is forwarded to the now-running container
- Subsequent requests flow through normally until the idle timeout triggers again
Wake time is your container's start-up time, because the image is already pulled and cached — no rebuild required. Most Node.js or Go images start in well under 10 seconds; JVM apps may need longer, so raise the wake timeout for them. The proxy and orchestrator live in the same binary, eliminating the network hop between "check state" and "start container." The settings are documented under on-demand previews and on-demand environments.
Savings in Practice
In the worked model above — 10 PR previews cost $50–100/mo, and a team that also keeps staging, QA and demo environments (20 in total) at $5–10/container/month, each actively used about 2 hours a day, pays $100–200/mo always-on, and on-demand mode removes most of the idle 22 hours. Your saving depends on how long previews sit idle and on your idle timeout; measure container-hours before and after. For how platforms compare on preview cost and access control, see the best platforms for preview deployments.
How Do You Minimize Cold Start Times?
Cold start latency is the main tradeoff of scale-to-zero. Google's Cloud Run tips cover the same levers: image size, startup dependencies, and how much work happens before the server listens. Which stop mechanism you use matters too.
Keep Images Small
Every megabyte of image size adds to pull and extract time, and a smaller app starts faster. How to reduce Docker image size covers the techniques.
# Unoptimized: full Debian-based image with dev dependencies
FROM node:24
COPY . .
RUN npm install
CMD ["node", "server.js"]
# Optimized: Alpine base, production dependencies only
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:24-alpine
WORKDIR /app
COPY --from=builder /app .
CMD ["node", "server.js"]
Use pause/unpause Instead of stop/start
docker pause freezes container processes using cgroups. Memory stays allocated but CPU drops to zero. docker unpause resumes in under 100 ms — no process startup involved.
The tradeoff: paused containers still consume RAM. For dev environments with 512 MB containers, this is often worth it. For staging environments running 2 GB containers, stop/start with the slower wake time might save more overall.
Set Appropriate Idle Timeouts
| Environment | Recommended Timeout | Rationale |
|---|---|---|
| PR previews | 5 minutes | Reviewed once, rarely revisited |
| Development | 10–15 minutes | Active work, short breaks |
| Staging | 30–60 minutes | QA sessions, longer gaps between tests |
| Demo | 15–30 minutes | Client meetings, unpredictable pauses |
When Should You NOT Use Scale-to-Zero?
Scale-to-zero is not universal. Here's where it causes more problems than it solves:
Production environments. Any environment where users expect sub-second responses. A multi-second cold start on the first request after idle is unacceptable for customer-facing applications.
CI/CD pipeline targets. Automated tests and deployment pipelines expect instant responses. A sleeping environment introduces flaky test results because the first request takes abnormally long or times out.
WebSocket-heavy applications. When containers sleep, all WebSocket connections drop. Applications need client-side reconnection logic, and if the app relies on persistent connections (real-time collaboration, live dashboards), the reconnection storm after a wake can overwhelm the freshly started container.
Long-running background jobs. Containers running cron jobs, queue workers, or batch processing should never scale to zero — their "traffic" isn't HTTP-based and they need to be running continuously.
The Right Environments for Scale-to-Zero
Scale-to-zero shines in environments with bursty, human-driven access patterns:
- PR preview environments (accessed during code review only)
- Development environments (active 8 hours, idle 16)
- QA/staging (used during test sessions, idle between)
- Demo environments (active during sales calls)
- Documentation preview (accessed during writing sessions)
Frequently Asked Questions
How long does a cold start take with scale-to-zero?
Cold start duration depends on the stop mechanism and the app. docker pause/unpause resumes almost instantly because the process stays in memory (so it saves CPU, not RAM). docker stop/start frees the memory but pays the app's full start-up time — seconds for a small Node.js or Go service, longer for large or JVM-based apps.
Can I use scale-to-zero for production?
Not recommended for user-facing production. The cold start penalty creates unacceptable latency for the first visitor after an idle period. Reserve scale-to-zero for dev, staging, preview, and demo environments where an occasional cold-start delay is acceptable.
What happens to WebSocket connections during scale-to-zero?
All WebSocket connections drop when a container sleeps. Clients receive a close frame (or a TCP reset if the stop is abrupt). Applications need client-side reconnection logic — most WebSocket libraries support automatic reconnect with exponential backoff. The first reconnect triggers a container wake, so expect a cold-start delay before the connection reestablishes.
How much money does scale-to-zero actually save?
Savings depend on how much of the day each environment sits idle. In our worked model — 20 non-production environments (10 PR previews plus staging, QA and demos) at $5–10/container/month, each used about 2 hours a day — always-on costs $100–200/mo and scale-to-zero removes most of the idle hours. Measure your own container-hours before and after to get a real number.
Does scale-to-zero work with databases?
The container's application stops, not the database. Database connections are closed when the container sleeps and re-established on wake. Use connection pooling (PgBouncer, Prisma connection pool) so the reconnection is fast. Database containers themselves should NOT use scale-to-zero — they need to persist data and maintain availability.
Does Temps support scale-to-zero for preview environments?
Yes. Set on_demand: true on any environment, or enable preview_envs_on_demand at the project level so every new PR preview inherits it automatically. Idle timeout defaults to 300 seconds (5 minutes), configurable from 60 seconds to 24 hours. Wake timeout defaults to 30 seconds, configurable from 5 to 120 seconds.
Start Saving on Idle Environments
Scale-to-zero is one of those rare optimizations that's pure upside for non-production environments. You stop paying for idle hours with a tradeoff that barely matters for non-production: cold-start latency on the first request after an idle period.
You can build it yourself with Docker, a lightweight proxy, and about 100 lines of daemon code. Or use a platform that handles the proxy buffering, health checking, and lifecycle management out of the box — with self-hosted pricing that doesn't add another SaaS bill.
Temps is licensed MIT / Apache 2.0, free to self-host on the server you already pay for, with Temps Cloud — a hosted control plane for your own servers — coming soon from $29/month. No per-seat fees, no bandwidth bills.
curl -fsSL https://temps.sh/deploy.sh | bash
Set on_demand: true on any environment, configure your idle timeout, and watch your resource usage drop while your environments stay accessible on demand.
Get weekly updates