DevOps ·

Docker to Serverless: A Migration Playbook for Production Applications

A practical migration guide for teams moving containerized applications to serverless platforms. Covers assessment, migration patterns, state handling, connection pooling, cost modeling, monitoring, and rollback strategies with TypeScript examples.

Docker to Serverless: A Migration Playbook for Production Applications

Most teams arrive at serverless migration the same way: a container workload costs more than expected at scale, or a new feature needs to run at the edge, and someone asks whether the existing Docker setup is the right tool. The answer depends on what the workload actually does.

This playbook covers the full migration path: assessment, safe sequencing, concrete problems (state, connections, cold starts), and rollback.

When Serverless Makes Sense

Before touching any code, answer these four questions honestly.

Is the workload stateless by design? Serverless function instances can be created and destroyed at any point. If your code holds in-memory state that subsequent requests depend on (session objects, connection handles, counters), the migration will break things in production before it breaks in testing.

Are execution times within platform limits? AWS Lambda has a 15-minute cap. Cloudflare Workers has a 30-second CPU time limit per request (10ms on the free plan). Vercel Edge Functions are capped at 25 seconds on the hobby tier. Long-running batch jobs, video processing pipelines, and report generators that take minutes to run are not candidates.

Is cold start latency acceptable? Node.js Lambda cold starts typically add 200-800ms on the first invocation after a period of inactivity. Cloudflare Workers uses V8 isolates rather than full containers, which reduces cold start to roughly 5ms. If you have p99 latency SLAs under 500ms and traffic is spiky or low-volume, you need to either pick the right platform (Workers for edge latency) or architect around warmup.

Does the workload need local filesystem access? Lambda provides a /tmp directory with 512MB-10GB depending on configuration, but it is ephemeral and not shared across instances. If your application reads config files from disk, uses SQLite, or writes to local log files that a log shipper reads, you need to extract that before migrating.

A rough filter: API routes that transform request data and call a database or external service are almost always good candidates. Workloads that accumulate state, run long, or rely on persistent local files are poor candidates.

The Assessment Checklist

Work through this before writing any migration code.

// Run this inventory across your routes/handlers to flag migration risks
interface MigrationRiskProfile {
  route: string;
  stateful: boolean;           // holds in-memory state across requests
  execTimeP99Ms: number;       // measured from your current APM
  filesystemDeps: string[];    // paths read/written during request
  connectionPooled: boolean;   // uses a persistent DB connection pool
  backgroundJobs: boolean;     // spawns async work after response
  websocketRequired: boolean;
}

function assessRoute(profile: MigrationRiskProfile): {
  viable: boolean;
  blockers: string[];
  warnings: string[];
} {
  const blockers: string[] = [];
  const warnings: string[] = [];

  if (profile.stateful) {
    blockers.push("In-memory state: extract to Redis or KV before migrating");
  }

  if (profile.execTimeP99Ms > 10_000) {
    blockers.push(`p99 exec time ${profile.execTimeP99Ms}ms exceeds safe serverless limits`);
  } else if (profile.execTimeP99Ms > 5_000) {
    warnings.push(`p99 exec time ${profile.execTimeP99Ms}ms is approaching limits under load spikes`);
  }

  if (profile.filesystemDeps.length > 0) {
    blockers.push(`Filesystem dependencies: ${profile.filesystemDeps.join(", ")}`);
  }

  if (profile.connectionPooled) {
    warnings.push("DB connection pool will not survive across cold starts: use a proxy (RDS Proxy, PgBouncer)");
  }

  if (profile.backgroundJobs) {
    blockers.push("Background work after response: move to a queue (SQS, Cloudflare Queues)");
  }

  if (profile.websocketRequired) {
    blockers.push("WebSocket connections require Cloudflare Durable Objects or a separate socket server");
  }

  return {
    viable: blockers.length === 0,
    blockers,
    warnings,
  };
}

Run this inventory across every route and handler. Routes with zero blockers can move immediately. Routes with warnings need architectural changes before or during migration. Routes with blockers need real work first.

Migration Patterns

There are three migration approaches, each with different risk and rollback properties.

You run the serverless version in parallel and route a percentage of traffic to it. The container handles 100% of traffic initially; the serverless function handles 0%. You shift traffic incrementally as confidence builds, then remove the container when the function handles 100%.

This requires a routing layer in front of both. A Cloudflare Worker or an Application Load Balancer rule can split traffic by percentage or by request attribute (user ID hash, header value, path prefix).

// Cloudflare Worker acting as a traffic splitter during migration
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Hash the request to get a stable 0-99 bucket for each user/IP
    const bucket = await stableBucket(request);

    // Start at 5, increase to 10, 25, 50, 100 over days/weeks
    const serverlessTrafficPercent = parseInt(
      env.SERVERLESS_TRAFFIC_PERCENT ?? "5"
    );

    if (bucket < serverlessTrafficPercent) {
      return routeToServerless(request, env);
    }

    return routeToContainer(request, env);
  },
};

async function stableBucket(request: Request): Promise<number> {
  const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
  const encoder = new TextEncoder();
  const data = encoder.encode(ip);
  const hashBuffer = await crypto.subtle.digest("SHA-256", data);
  const hashArray = new Uint8Array(hashBuffer);
  return hashArray[0] % 100;
}

Monitor error rates and latency for each traffic slice independently. If the serverless slice degrades, set SERVERLESS_TRAFFIC_PERCENT back to 0.

Endpoint-by-Endpoint

Migrate one route at a time, fully, before moving to the next. No traffic splitting. Route /api/users to the function, leave everything else on the container. This is lower complexity but requires that routes are truly independent (no shared in-process state between them, which they should not have anyway).

This works well for API services with clear route boundaries and for teams that want to validate serverless behavior on low-risk routes (read-only GETs, non-critical paths) before touching writes.

Full Cutover

Migrate everything at once in a maintenance window. Only appropriate for low-traffic internal tools, staging environments, or workloads where you have high confidence from prior assessment. Do not use this for user-facing production traffic unless you have a tested rollback path ready to execute in under five minutes.

Handling State

The most common migration failure mode is undiscovered state. Things that look stateless at the route level often have global singletons somewhere in the module graph.

In-memory caches are the most common culprit. A module-level Map that caches database lookups works on a container (same process, shared cache) but will be empty on every new function instance. Extract these to Redis or a KV store before migration:

// Before: container-local cache (breaks on serverless)
const userCache = new Map<string, User>();

async function getUser(id: string): Promise<User> {
  if (userCache.has(id)) return userCache.get(id)!;
  const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  userCache.set(id, user);
  return user;
}

// After: Redis-backed cache (works across instances)
import { createClient } from "redis";

const redis = createClient({ url: process.env.REDIS_URL });

async function getUser(id: string): Promise<User> {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached) as User;

  const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  await redis.setEx(`user:${id}`, 300, JSON.stringify(user)); // 5-minute TTL
  return user;
}

On Cloudflare Workers, the KV store is the natural replacement for simple key-value caching. For more complex stateful coordination (websocket rooms, counters that need strong consistency), Durable Objects give you a single-threaded actor per logical entity.

Session state in process memory needs to move to a session store. JWT tokens with Redis-backed revocation lists or database-backed sessions both work. Confirm the store is accessible from the function’s network context (VPC placement for Lambda, global reachability for Workers).

Database Connection Management

This is the most concrete operational problem in a Lambda migration from a container.

A container process starts once and maintains a pool of database connections for its lifetime. You might configure pg with max: 10 and that pool stays open across thousands of requests. A Lambda function can have hundreds of concurrent instances, each creating its own connections. At 100 concurrent Lambda instances with a pool of 5 each, you have 500 connections to a Postgres instance that might have a max_connections setting of 100.

There are two solutions:

Connection proxies (RDS Proxy for AWS, PgBouncer for self-hosted) sit between your functions and the database. They maintain a real connection pool and multiplex requests from many function instances over fewer actual database connections. RDS Proxy adds roughly 1ms of overhead and handles up to 65,535 connections on the proxy side.

// Lambda handler with minimal pool size (proxy handles multiplexing)
import { Pool } from "pg";

// Create outside the handler to reuse across warm invocations
// Keep max low: the proxy aggregates across instances
const pool = new Pool({
  host: process.env.DB_PROXY_HOST,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  max: 2,       // 2 connections per warm instance
  idleTimeoutMillis: 1000,
  connectionTimeoutMillis: 3000,
});

export const handler = async (event: APIGatewayProxyEvent) => {
  const client = await pool.connect();
  try {
    const result = await client.query(
      "SELECT * FROM orders WHERE user_id = $1",
      [event.pathParameters?.userId]
    );
    return {
      statusCode: 200,
      body: JSON.stringify(result.rows),
    };
  } finally {
    client.release();
  }
};

HTTP-based database clients (Neon, PlanetScale, Turso) expose a REST or WebSocket API instead of a persistent TCP connection. There is no connection state to manage, which is the natural fit for Cloudflare Workers. Latency per query runs 10-30ms higher than a direct connection, which is acceptable for read-heavy API patterns but worth measuring on write-heavy paths.

Monitoring Differences

The observability model changes significantly. With containers you have continuous process metrics: memory usage over time, connection counts, CPU trends. With serverless you have per-invocation records.

MetricContainer (Docker)Serverless (Lambda/Workers)
Cold start visibilityNot applicableCritical: track init duration vs execution duration separately
Memory usageProcess-level RSS over timePer-invocation max memory used
ConcurrencyThread/connection countsConcurrent invocation count (set reserved concurrency to cap it)
Error ratesApplication logs + APMStructured invocation logs + dead letter queues
TimeoutsLoad balancer 504 or keep-alive limitsPlatform-enforced hard timeout: track duration approaching limit
CostFixed monthly instance costPer-invocation (GB-seconds for Lambda, CPU milliseconds for Workers)

For Lambda, emit structured logs per invocation at minimum: requestId, durationMs, coldStart, memoryUsedMb, statusCode. Ship to CloudWatch Logs Insights or forward via a subscription filter.

// Wrapper that emits structured metrics on every invocation
function withMetrics<T>(
  handler: (event: T) => Promise<APIGatewayProxyResult>
) {
  return async (event: T, context: Context): Promise<APIGatewayProxyResult> => {
    const start = Date.now();
    let statusCode = 500;

    try {
      const result = await handler(event);
      statusCode = result.statusCode;
      return result;
    } finally {
      const durationMs = Date.now() - start;
      // Lambda sets this env var to "1" on cold start
      const coldStart = process.env.AWS_LAMBDA_INITIALIZATION_TYPE === "on-demand";

      console.log(JSON.stringify({
        requestId: context.awsRequestId,
        durationMs,
        coldStart,
        memoryUsedMb: Math.round(process.memoryUsage().rss / 1024 / 1024),
        memoryAllocatedMb: parseInt(context.memoryLimitInMB),
        statusCode,
      }));
    }
  };
}

Cost Modeling

The frequently cited advantage of serverless is cost. The frequently undiscussed caveat is that it is only cheaper at certain traffic patterns.

Lambda pricing: $0.0000166667 per GB-second plus $0.20 per 1M requests. A 256MB function at 100ms costs $0.000000427 per invocation. At 1M requests/month that is roughly $0.83, versus about $15/month for two t3.micro instances. At 100M requests/month Lambda reaches $83, while two t3.small instances cost $30 but do not handle that volume.

The crossover point depends on function duration and memory. Long-running or memory-intensive functions favor containers. Short, bursty, or low-traffic functions favor serverless. Build a model from your actual p50 duration and memory before committing.

Cloudflare Workers pricing: $0.30 per million requests after the first 10M, plus CPU time at $12.50 per million CPU-seconds. For edge routing and transformation workloads with low CPU per request, Workers is typically cheaper than Lambda for the same request volume.

Rollback Strategy

Define the rollback before you start migrating. The rollback should be executable in under five minutes without a deployment.

For the strangler fig pattern, rollback is a configuration change: set SERVERLESS_TRAFFIC_PERCENT to 0. The container never stopped running, so traffic reverts immediately. This is why you should keep the container running through the migration and not decommission it until you have had at least 30 days of stable serverless operation.

For endpoint-by-endpoint, maintain a feature flag or routing rule per endpoint. A Cloudflare Worker routing table or an ALB listener rule can be changed without a deployment.

// Feature flag check at the edge: rollback is a KV write
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    // Check if this endpoint is flagged back to container
    const routeFlag = await env.FEATURE_FLAGS.get(
      `route:${url.pathname}`
    );

    if (routeFlag === "container") {
      return fetch(new Request(
        `${env.CONTAINER_ORIGIN}${url.pathname}${url.search}`,
        request
      ));
    }

    // Route to serverless handler
    return handleRequest(request, env);
  },
};

Document the rollback as a runbook: who executes it, what action triggers it, how to confirm it took effect, and what the blast radius is if it fails (it should be zero, because the container is still running).

Tradeoffs at a Glance

DimensionContainerServerless
Cold startNot applicable (always warm)5ms (Workers) to 800ms (Lambda JVM)
Execution time limitUnlimited15min (Lambda), 30s CPU (Workers)
State managementIn-process possible, but riskyExternal store required
DB connectionsPool survives across requestsProxy required or HTTP client
Burst scalingMinutes (new container spin-up)Seconds (Lambda), sub-second (Workers)
Cost at low trafficFixed overheadNear zero
Cost at high trafficPredictableVariable; can exceed containers at sustained load
Local dev experienceDocker Compose, full parityPlatform-specific emulators, some gaps
Operational surfaceContainer orchestration (ECS, k8s)Platform-managed; limited tuning surface

What the Migration Actually Takes

A realistic timeline for migrating a mid-size Express API (20-30 routes, PostgreSQL, Redis) to Lambda using the strangler fig pattern: week 1 for assessment and state extraction, week 2 for routing layer and first three routes at 5% traffic, weeks 3-4 for remaining routes at 25-50%, then 30 days at 100% before decommissioning containers.

The database connection work is consistently the longest task. Teams that skip the proxy step hit connection limit errors under load within the first week of higher traffic percentages.

Serverless is not inherently simpler than containers. It trades one set of operational concerns (container orchestration, instance sizing, scaling policies) for another (connection management, cold start tuning, external state, execution limits). Whether the trade is worth it depends on your specific traffic profile, team expertise, and cost structure.

The assessment checklist and the strangler fig pattern reduce the risk of finding out those tradeoffs are wrong in production rather than in planning.

More in DevOps

How Terraform Works Internally: HCL Parsing, Dependency Graph Execution, and the Provider Protocol Behind Infrastructure as Code
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
DevOps ·

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
DevOps ·

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
DevOps ·

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.