Docker to Serverless: A Migration Playbook for Startups
A practical migration playbook for moving startup workloads from Docker-based infrastructure to serverless platforms. Learn workload selection, architecture patterns, TypeScript implementation, rollout strategy, and production risk controls.
Most startups start with Docker because it is flexible and familiar.
You get predictable local development, one deployment artifact, and a fast path from prototype to production. Then growth happens. Suddenly the team is spending more time on CI queue tuning, autoscaling knobs, cluster incidents, and patching base images than on product work.
That is where serverless becomes interesting.
The point of migration is not trend-following. The point is reclaiming engineering time while keeping reliability stable. This guide gives you a practical, production-focused path from Docker workloads to serverless.
When migration is worth it
Serverless is a good fit when:
- Traffic is spiky or unpredictable
- Team is small and cannot babysit infra
- Workloads are API-first, event-driven, or queue-driven
- Fast release velocity matters more than low-level host control
Serverless is usually a bad fit when:
- You run long-lived CPU-heavy jobs with strict runtime needs
- You depend on specialized kernel or networking behavior
- You need hard real-time guarantees
- Your cost profile is stable at very high sustained load where reserved compute wins
Do not migrate because “serverless scales”. Docker scales too. Migrate when ops load is blocking product velocity.
Migration principles that prevent painful rewrites
Use these principles from day one:
- Strangle, do not rewrite: migrate one capability at a time.
- Separate compute from state: stateful assumptions inside containers are your main blocker.
- Preserve contracts: keep HTTP, queue, and event contracts stable while internals move.
- Measure before and after: latency, error rate, and cost per request.
- Make rollback boring: every step should have a fast fallback path.
If you ignore principle #2, migration gets expensive fast.
Step 1: Inventory your Docker workloads
Classify each service before touching code.
| Workload | Typical Docker shape | Serverless fit | Notes |
|---|---|---|---|
| Public API gateway | Long-running Node service | High | Good first migration target |
| Webhook handlers | API route + retry logic | High | Event model matches serverless |
| Background jobs | Worker container + queue | Medium to high | Move to queue-triggered functions |
| Cron tasks | Container scheduled job | High | Use platform scheduler + idempotency |
| WebSocket fanout | Stateful process | Medium | Needs durable pub/sub strategy |
| ML inference CPU/GPU | Heavy container runtime | Low to medium | Often better on specialized compute |
Pick one service with high fit and moderate business impact for wave 1.
Step 2: Extract platform-agnostic domain logic
Many teams couple business logic to Express/Koa middleware and process-wide state. That creates friction.
Refactor into pure application services first.
// domain/order-service.ts
export type CreateOrderInput = {
customerId: string;
items: { sku: string; qty: number }[];
};
export type OrderRepository = {
save(input: {
id: string;
customerId: string;
totalCents: number;
items: { sku: string; qty: number }[];
}): Promise<void>;
};
export async function createOrder(
repo: OrderRepository,
input: CreateOrderInput,
): Promise<{ orderId: string; totalCents: number }> {
if (input.items.length === 0) throw new Error("order must contain items");
const totalCents = input.items.reduce((acc, item) => acc + item.qty * 2500, 0);
const orderId = crypto.randomUUID();
await repo.save({ id: orderId, customerId: input.customerId, totalCents, items: input.items });
return { orderId, totalCents };
}
Then build thin adapters for Docker and serverless runtimes. This lets you migrate runtime without rewriting business rules.
Step 3: Choose target serverless architecture
Do not pick services randomly. Pick an architecture shape.
A common startup-friendly stack:
- Edge/API compute: Cloudflare Workers or AWS Lambda
- Queue processing: Cloudflare Queues, SQS, or Pub/Sub
- State: managed DB (Postgres, DynamoDB, D1) + object storage
- Cache: Redis-compatible managed service or platform KV
- Secrets: managed secret store
Decision matrix
| Concern | Docker/Kubernetes | Serverless |
|---|---|---|
| Infra control | Full control | Limited, provider-specific |
| Cold starts | None | Possible, design required |
| Ops burden | High | Lower |
| Deployment complexity | Medium to high | Low to medium |
| Cost at low/moderate scale | Can be inefficient | Often efficient |
| Cost at very high steady load | Can be optimized hard | Can become expensive |
For most seed to Series A teams, reduced ops burden is the winning variable.
Step 4: Migrate ingress traffic safely
Keep your existing Docker service as fallback while introducing serverless on a percent basis.
Practical sequence:
- Put API traffic behind one routing layer (gateway, CDN, or load balancer)
- Route 1 to 5 percent to serverless path
- Compare p95 latency and error rate side by side
- Increase traffic only when metrics are neutral or better
If your routing layer supports headers, start with internal users first.
Example Worker adapter
// worker/src/index.ts
import { createOrder } from "./domain/order-service";
import { makeOrderRepo } from "./infra/order-repo";
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST" || new URL(request.url).pathname !== "/orders") {
return new Response("not found", { status: 404 });
}
try {
const body = (await request.json()) as {
customerId: string;
items: { sku: string; qty: number }[];
};
const result = await createOrder(makeOrderRepo(env.DB), body);
return Response.json(result, { status: 201 });
} catch (error) {
return Response.json(
{ error: error instanceof Error ? error.message : "unexpected error" },
{ status: 400 },
);
}
},
};
Keep the same request and response contract as Docker until final cutover.
Step 5: Move background processing to events
Container workers often poll queues in long loops. In serverless, invert the model. Let the platform invoke code per message batch.
Critical controls:
- Idempotency key for each job
- Dead-letter queue for poison messages
- Retry policy with exponential backoff
- Visibility into retries and age of oldest message
export type JobMessage = {
idempotencyKey: string;
orderId: string;
type: "send-receipt" | "sync-crm";
};
export async function handleJob(
hasProcessed: (key: string) => Promise<boolean>,
markProcessed: (key: string) => Promise<void>,
msg: JobMessage,
): Promise<void> {
if (await hasProcessed(msg.idempotencyKey)) return;
if (msg.type === "send-receipt") {
// send email
} else if (msg.type === "sync-crm") {
// call CRM API
}
await markProcessed(msg.idempotencyKey);
}
Without idempotency, retries turn minor incidents into data corruption incidents.
Step 6: Handle state, files, and sessions explicitly
Docker apps often write temp files or cache local state. In serverless, local filesystem and process memory are not reliable between invocations.
Migration rules:
- Move user uploads to object storage directly
- Move session state to external store (signed cookies or managed session store)
- Move in-memory caches to managed cache if consistency matters
- Use short-lived in-function cache only for performance hints
A fast audit question: “If this function runs on a new machine right now, do we break?” If yes, externalize that state.
Step 7: Fix observability before full cutover
Teams underinvest here and regret it.
Minimum dashboards:
- Request count, latency (p50/p95/p99), error rate by route
- Cold start frequency and impact window
- Queue depth, retry count, dead-letter volume
- Dependency health: DB, third-party APIs, auth provider
- Cost per million requests and per workflow
Add structured logs with correlation IDs.
type LogContext = {
requestId: string;
route: string;
customerId?: string;
};
function logInfo(ctx: LogContext, event: string, fields: Record<string, unknown>) {
console.log(JSON.stringify({ level: "info", event, ...ctx, ...fields, ts: new Date().toISOString() }));
}
When something fails during phased migration, correlation IDs are how you prove whether failure happened in Docker path or serverless path.
Step 8: Run staged cutover with hard guardrails
Use a promotion ladder and clear stop conditions.
Suggested ladder:
- Stage 0: internal users only
- Stage 1: 5 percent external traffic
- Stage 2: 25 percent
- Stage 3: 50 percent
- Stage 4: 100 percent
Stop and roll back if any of these conditions trip for more than 5 minutes:
- Error rate increases by more than 1.5x baseline
- p95 latency degrades by more than 25 percent
- Queue retry rate crosses defined threshold
- Checkout or activation conversion drops materially
Make rollback one command or one routing change. If rollback requires code changes, your plan is too fragile.
Common migration traps
Trap 1: Moving everything at once
Result: hard-to-debug failures across multiple systems.
Fix: one bounded workflow at a time.
Trap 2: Ignoring provider limits
Every platform has limits on CPU time, payload size, concurrency, and network behavior.
Fix: map limits early and add tests that simulate edge cases.
Trap 3: Treating cost as purely lower
Serverless can be cheaper, but unbounded invocation fanout or chatty workflows can reverse that quickly.
Fix: track unit economics per workflow from day one.
Trap 4: Missing local parity
If local development becomes painful, team velocity drops.
Fix: standardize local mocks and contract tests so developers can ship without cloud dependency for every test run.
A realistic 4-week migration plan for small teams
Week 1: Assessment and extraction
- Inventory services and classify fit
- Extract domain logic from runtime-specific layers
- Define migration success metrics
Week 2: First serverless path in shadow mode
- Deploy first function behind routing layer
- Mirror or partial-route low-risk traffic
- Validate behavior and instrumentation
Week 3: Event workers and reliability controls
- Migrate one background worker to event-driven invocation
- Add idempotency, dead-letter queue, and alerts
- Run failure drills and rollback rehearsal
Week 4: Production cutover and cleanup
- Graduate traffic to 100 percent if guardrails hold
- Keep Docker path as fallback for a fixed window
- Decommission unused containers and update runbooks
This timeline is aggressive but realistic for one focused squad.
Decommissioning Docker responsibly
After stable cutover:
- Remove stale CI jobs building retired images
- Delete unused registries and credentials
- Archive old operational dashboards
- Update incident playbooks and on-call docs
- Keep one documented rollback image for a limited retention period
Do not leave half-dead infra in place. It creates hidden security and operational risk.
Final checklist
Before declaring migration complete:
- Domain logic decoupled from container runtime
- Stateful dependencies externalized
- Queue workflows idempotent and observable
- Rollout guardrails defined and tested
- Fast rollback proven in production-like conditions
- Unit economics monitored per workflow
- Decommission plan executed
Closing
A Docker to serverless migration is a business decision disguised as a technical one.
If your team is spending too much time operating infrastructure and not enough time shipping customer value, migration can reset your execution speed. Do it incrementally, instrument everything, and treat rollback as a first-class feature.
That is how you get serverless upside without gambling uptime.
More in DevOps
How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
A deep dive into Terraform's internal architecture covering HCL parsing, the expression evaluation engine, DAG-based dependency resolution, the gRPC provider plugin protocol, state mechanics, and the plan/apply lifecycle.
How Docker Works Internally: Namespaces, Cgroups, Union Filesystems, and the OCI Runtime Behind Every Container
A deep dive into what happens when you run docker run: Linux namespaces for process isolation, cgroups v2 for resource enforcement, overlay2 union filesystem for layered images, the OCI runtime spec and how runc spawns containers, content-addressable storage, the containerd shim architecture, and networking with veth pairs.
How Kubernetes Works: Pods, Scheduling, and the Reconciliation Loop
A deep dive into Kubernetes internals covering the control plane architecture, etcd watch mechanics, the scheduler's filter and score phases, controller manager reconciliation loops, kubelet pod assignment, the CRI and CNI plugin models, and production considerations for resource requests, pod disruption budgets, and cluster autoscaler behavior.
How Docker Works: Namespaces, Cgroups, Union Filesystems, and the Container Runtime from Build to Execution
A deep dive into Docker internals covering Linux namespace isolation, cgroup resource constraints, OverlayFS copy-on-write layer mechanics, Dockerfile build cache invalidation rules, the containerd and runc OCI runtime stack, and production considerations for image hardening and startup latency.