---
title: Run a Docker monolith with workers on Vercel
description: Run a Dockerized monolith on Vercel with Container Images, move long-running workers to Vercel Queues and Vercel Workflows, and keep previews for every PR.
url: "https://vercel.com/kb/guide/docker-monolith-workers-vercel"
published: 2026-09-30
last_updated: 2026-09-30
authors: Vercel
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

Vercel runs Docker container images as request-driven Vercel Functions on Fluid compute, so the HTTP surface of a Dockerized monolith deploys to Vercel while its always-on worker processes need a different shape. Container images on Vercel scale out with traffic and scale down when traffic stops, which means a worker loop that never receives an HTTP request has nothing keeping it running.

Most worker types have direct replacements on Vercel: queue consumers become Vercel Queues push consumers, multi-step jobs become Vercel Workflows, and scheduled tasks become Cron Jobs. Every pull request then gets a preview deployment that includes all services and their own isolated queue topics.

Processes that must run continuously can stay on Render, Railway, Amazon ECS, or Kubernetes and exchange work with Vercel through a shared queue such as Amazon SQS or Redis.

## Overview

In this guide, you'll learn how to:

- Decide which processes in a Dockerized monolith fit Vercel and which need another home
  
- Deploy the web and API process as a container image service
  
- Replace queue consumers, multi-step jobs, and scheduled tasks with Vercel Queues, Vercel Workflows, and Cron Jobs
  
- Keep preview deployments isolated for every pull request, including background jobs and data
  
- Connect always-on workers on another platform in a hybrid architecture
  

## Prerequisites

Before you begin, make sure you have:

- A Dockerized monolith in a Git repository connected to a Vercel project, or the [Vercel CLI](https://vercel.com/docs/cli) installed
  
- A list of the monolith's process types, usually found in a `Procfile`, `compose.yaml`, `supervisord.conf`, or Kubernetes manifests
  
- The Docker CLI and a running Docker daemon, which `vercel dev` needs to run container images locally
  
- A plan for persistent state, such as a Postgres or Redis database from the [Vercel Marketplace](https://vercel.com/marketplace)
  

Container images and Vercel Services are in beta and available on all plans. Vercel Queues is also in beta and available on all plans.

## Can a Dockerized monolith run on Vercel?

A Dockerized monolith runs on Vercel when its runtime entrypoint is an HTTP server. Vercel builds a `Dockerfile.vercel` (or `Containerfile.vercel`) into an OCI image, stores it in [Vercel Container Registry](https://vercel.com/docs/container-registry), and runs it as a Vercel Function that scales with traffic. Each container instance is stateless, so persistent state has to live in backing services such as a Marketplace database or Vercel Blob. Worker processes that never receive an HTTP request don't run on Vercel as-is. You decompose them into Vercel Queues consumers, Vercel Workflows, or Cron Jobs, or you keep them on a platform built for always-on processes.

### How Vercel runs container images

Vercel treats a container image as a Vercel Function with the same limits and Active CPU pricing as any other function. The behavior that matters for a monolith is:

- **HTTP only**: The container must open an HTTP server. Vercel routes traffic to port `80` by default, and you can override it by setting the `PORT` environment variable in your project settings.
  
- **Scale to zero**: Instances with no traffic for 5 minutes in production scale down automatically. In preview environments, the threshold is 30s.
  
- **Graceful shutdown**: On scale-down, the container receives a `SIGTERM` signal with a 30s grace period before Vercel terminates it.
  
- **Stateless instances**: Nothing written to the local filesystem or process memory survives between instances.
  
- **Duration limits**: Each request is bound by the function's `maxDuration`, which defaults to 300s and goes up to 800s on Pro and Enterprise. The 1800s extended maximum beta only covers specific Node.js, Bun, and Python runtime versions, and container images aren't on that list, so plan for 800s.
  
- **Networking gaps**: [Secure Compute](https://vercel.com/docs/networking/secure-compute) and [Static IPs](https://vercel.com/docs/networking/static-ips) aren't supported with container images yet.
  

### Why an always-on worker loop doesn't fit container images

A typical worker command, such as `celery worker`, `sidekiq`, or a BullMQ `Worker`, opens a connection to a broker and loops forever waiting for jobs. That process never serves an HTTP request, so Vercel has no request to route to it and no traffic to keep the instance alive. A worker started as a background loop inside the web container also stops working, because the instance scales down after 5 minutes without HTTP traffic in production and 30s in preview. Vercel inverts the model instead. Vercel Queues holds the work and invokes a private consumer function for each message, so compute only runs when there's a job to process.

## Which Vercel runtime each monolith process maps to

Map every process type in your monolith to one row of this table before changing any code. The worker type, not the fact that it's a worker, decides where it runs.

| Monolith process                                                                                | Example start command                                     | Where it runs    | Vercel primitive                                                                                         |
| ----------------------------------------------------------------------------------------------- | --------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------- |
| Web server or API                                                                               | `node src/server.js`, `gunicorn app.wsgi`, `rails server` | Vercel           | Container image in a [Vercel Services](https://vercel.com/docs/services) project                         |
| Fire-and-forget jobs (emails, PDFs, webhooks)                                                   | `celery worker`, `sidekiq`, BullMQ `Worker`               | Vercel           | [Vercel Queues](https://vercel.com/docs/queues) push consumer                                            |
| Multi-step or long-waiting jobs (onboarding, billing runs, approvals)                           | Chained jobs with manual state tracking                   | Vercel           | [Vercel Workflows](https://vercel.com/docs/workflows)                                                    |
| Scheduled tasks                                                                                 | `cron`, `celery beat`, `whenever`                         | Vercel           | [Cron Jobs](https://vercel.com/docs/cron-jobs) that enqueue work or start a workflow                     |
| Always-on daemons (persistent broker or socket connections, local disk, privileged host access) | Custom daemon or sidecar                                  | Another platform | Render, Railway, Amazon ECS, or Kubernetes, connected through a shared queue such as Amazon SQS or Redis |

Vercel Queues and Vercel Workflows solve different problems. Vercel Queues gives you direct control over topics, messages, and consumer groups, which fits one-message-one-job work. Vercel Workflows builds on Vercel Queues and adds durable steps, `sleep`, and hooks, so a job can run across many function invocations and pause for minutes to months without a duration limit.

## Steps

The steps below use a Node.js Express monolith with a web process, a job worker, and a scheduler as the running example. The same decomposition applies to Python, Ruby, Go, and Java monoliths. Python projects can use the [Celery](https://vercel.com/docs/frameworks/backend/celery) and [Dramatiq](https://vercel.com/docs/frameworks/backend/dramatiq) integrations, which compile existing workers into private queue-triggered functions. These integrations run on the Python runtime and declare their workers in `pyproject.toml`, so put the worker code in a Python service next to the container image rather than inside it. Service bindings aren't available yet for services on the Go or Rust runtime, so build a Go service as a container image if it needs to call another service.

### 1\. Inventory the monolith's process types

List every process the monolith starts in production. A `Procfile` for the example monolith looks like this:

```text
web: node src/server.js
worker: node src/worker.js
scheduler: node src/scheduler.js
```

For each process, record what triggers it, how long one unit of work takes, and whether it depends on local disk or a persistent connection. Then assign it a row from the mapping table above. In the example, `web` becomes a container image service, `worker` becomes a Vercel Queues consumer, and `scheduler` becomes a Cron Job that starts a Vercel Workflow.

A unit of work that regularly runs longer than your function's `maxDuration` belongs in Vercel Workflows, where you split it into steps that each finish within the limit.

### 2\. Move durable state out of the container

Container instances on Vercel keep nothing between requests, so every piece of state the monolith writes locally needs a backing service. Replace each local dependency before you deploy:

- **Database containers**: Attach a managed database from the Vercel Marketplace, which injects its connection string as an environment variable. For example, run `vercel install neon` for Postgres or `vercel install upstash` for Redis.
  
- **Uploaded or generated files**: Move them to [Vercel Blob](https://vercel.com/docs/storage) instead of a mounted volume.
  
- **Sessions and caches**: Store them in Redis or your database instead of process memory.
  
- **Job progress**: Persist it in the database so a restarted consumer can resume or skip completed work.
  

Your application code keeps its existing Postgres or Redis client and reads the connection string from the injected environment variable.

### 3\. Deploy the web and API process as a container image

Move the monolith into a `web/` directory and add a `Dockerfile.vercel` next to its `package.json`. The image only needs to start the HTTP server:

```dockerfile
FROM node:24-alpine

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .

CMD ["node", "src/server.js"]
```

The server listens on `PORT` when you set it and falls back to Vercel's default port `80`. It also closes cleanly on `SIGTERM` so in-flight requests finish during the 30s grace period:

```javascript
import express from 'express';

const app = express();
app.use(express.json());

app.get('/healthz', (req, res) => res.json({ ok: true }));

const port = Number(process.env.PORT ?? 80);
const server = app.listen(port, '0.0.0.0', () => {
  console.log(`Listening on port ${port}`);
});

process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
  // Exit before the 30s grace period ends if connections don't drain
  setTimeout(() => process.exit(1), 25_000).unref();
});
```

Remove any code that starts the worker loop or scheduler from inside the web process. Those processes move to the jobs service in the next steps.

### 4\. Add a jobs service and route traffic between services

Create a `jobs/` directory with a Next.js app that will hold queue consumers and workflows. The Vercel Queues docs configure push consumers on framework function paths, such as a Next.js route handler, so the consumers live in a framework service next to the container image.

Declare both services in a `vercel.json` at the repository root:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "services": {
    "web": {
      "root": "web/",
      "runtime": "container",
      "entrypoint": "Dockerfile.vercel",
      "bindings": [
        {
          "type": "service",
          "service": "jobs",
          "format": "url",
          "env": "JOBS_INTERNAL_URL"
        }
      ]
    },
    "jobs": {
      "root": "jobs/",
      "functions": {
        "app/api/queues/send-invoice/route.ts": {
          "experimentalTriggers": [
            { "type": "queue/v2beta", "topic": "invoices", "maxDeliveries": 10 }
          ]
        }
      }
    }
  },
  "rewrites": [
    { "source": "/(.*)", "destination": { "service": "web" } }
  ],
  "crons": [
    { "path": "/api/cron/nightly-reconcile", "schedule": "0 3 * * *" }
  ]
}
```

This configuration does four things:

- **Public traffic**: The rewrite sends every public request to the `web` container. The `jobs` service has no rewrite, so it receives no public traffic.
  
- **Internal calls**: The binding on `web` injects the `jobs` service's internal URL as `JOBS_INTERNAL_URL`. Traffic over a binding stays on Vercel's internal network, and the injected URL is deployment-aware, so a preview's `web` always calls the same preview's `jobs`. A binding grants reachability but doesn't authenticate the caller, so keep authorization between services in your code if it matters.
  
- **Queue consumer**: The `queue/v2beta` trigger makes `send-invoice/route.ts` a private consumer of the `invoices` topic. `maxDeliveries` caps retries for a message that keeps failing.
  
- **Schedule**: The cron entry calls `/api/cron/nightly-reconcile` on the production deployment at 03:00 UTC every day. The cron request is an HTTP `GET` to that path on the production URL, which the catch-all rewrite sends to `web`, where step 6 adds the handler. Step 6 shows how to confirm this on your first production deployment. Vercel doesn't retry a failed cron invocation, which is another reason to keep the cron handler thin.
  

Enable Vercel Workflows in the jobs service by installing `workflow` and `@vercel/queue`, then wrapping the Next.js config:

```bash
npm i workflow @vercel/queue
```
```typescript
import { withWorkflow } from 'workflow/next';
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {};

export default withWorkflow(nextConfig);
```

Without `withWorkflow()`, the `'use workflow'` and `'use step'` directives compile to no-ops.

### 5\. Convert the first queue consumer

Pick one low-risk job, such as sending an invoice email, and move it off the old worker. The web process publishes work through the jobs service, and Vercel Queues invokes the consumer for each message.

Add an internal route in the jobs service that publishes to the `invoices` topic. The `idempotencyKey` drops duplicate publishes for the same invoice during the message's retention period:

```typescript
import { send } from '@vercel/queue';

export async function POST(request: Request) {
  const { invoiceId } = await request.json();

  const { messageId } = await send(
    'invoices',
    { invoiceId },
    { idempotencyKey: `invoice-${invoiceId}` },
  );

  return Response.json({ messageId });
}
```

Add the consumer. Vercel acknowledges the message when the handler returns and redelivers it when the handler throws:

```typescript
import { handleCallback } from '@vercel/queue';
import { sendInvoice } from '@/lib/invoices';

export const POST = handleCallback(
  async (message: { invoiceId: string }) => {
    // sendInvoice must be idempotent: check a "sent" flag in the database
    // before sending, because Vercel Queues delivers at least once
    await sendInvoice(message.invoiceId);
  },
  {
    retry: (error, metadata) => {
      if (metadata.deliveryCount >= 5) {
        return { acknowledge: true };
      }
      return { afterSeconds: Math.min(300, 2 ** metadata.deliveryCount * 5) };
    },
  },
);
```

The `retry` callback drops a message after 5 failed deliveries. The trigger's `maxDeliveries: 10` from step 4 is a backstop for failures the callback never sees, such as a function timeout or crash, where the handler doesn't get to run its retry logic.

Replace the old enqueue call in the web process with a call to the jobs service over the binding:

```javascript
app.post('/api/invoices/:id/send', async (req, res) => {
  const response = await fetch(
    new URL('api/enqueue/invoice', process.env.JOBS_INTERNAL_URL),
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ invoiceId: req.params.id }),
    },
  );

  if (!response.ok) {
    return res.status(502).json({ error: 'enqueue_failed' });
  }

  res.status(202).json(await response.json());
});
```

Deploy and send a request to the new endpoint:

```bash
curl -X POST https://your-preview-url.vercel.app/api/invoices/inv_123/send
```

The response includes a `messageId`, and the jobs service's runtime logs show the consumer processing the message. Publishing through the jobs service keeps every Vercel Queues call in a framework service, where the SDK's credential handling is documented.

### 6\. Convert scheduled and multi-step jobs

Scheduled jobs work best as thin triggers that start durable work and return. A cron request is bound by the function's `maxDuration`, while a workflow can run across many invocations with no duration limit.

Define the nightly reconciliation as a workflow in the jobs service. Each `'use step'` function is persisted, so a failure retries only the failed step:

```typescript
import { listAccountsToReconcile, reconcileAccount } from '@/lib/billing';

export async function nightlyReconcile(date: string) {
  'use workflow';

  const accountIds = await loadAccounts(date);

  for (const accountId of accountIds) {
    await reconcile(accountId, date);
  }

  return { date, reconciled: accountIds.length };
}

async function loadAccounts(date: string) {
  'use step';
  // Returns every account with outstanding work since the last successful run,
  // so a missed night catches up on the next run
  return listAccountsToReconcile(date);
}

async function reconcile(accountId: string, date: string) {
  'use step';
  // Skips accounts already reconciled for this date, so duplicate runs are safe
  await reconcileAccount(accountId, date);
}
```

Add an internal route that starts a run:

```typescript
import { start } from 'workflow/api';
import { nightlyReconcile } from '@/workflows/nightly-reconcile';

export async function POST(request: Request) {
  const { date } = await request.json();
  const run = await start(nightlyReconcile, [date]);

  return Response.json({ runId: run.runId });
}
```

Add the cron route to the web container. It checks the `CRON_SECRET` environment variable, starts the workflow over the binding, and returns within seconds:

```javascript
app.get('/api/cron/nightly-reconcile', async (req, res) => {
  const cronSecret = process.env.CRON_SECRET;
  if (!cronSecret || req.get('authorization') !== `Bearer ${cronSecret}`) {
    return res.status(401).end();
  }

  const date = new Date().toISOString().slice(0, 10);
  const response = await fetch(
    new URL('api/workflows/nightly-reconcile', process.env.JOBS_INTERNAL_URL),
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ date }),
    },
  );

  if (!response.ok) {
    return res.status(502).json({ error: 'workflow_start_failed' });
  }

  res.json(await response.json());
});
```

Set `CRON_SECRET` to a random string of at least 16 characters in every environment that serves this route, including Preview. The handler rejects all requests when the variable is missing, and a failed workflow start returns a non-2xx status so it shows up in the cron logs.

Cron delivery is best effort. A scheduled run can occasionally be skipped or invoked more than once, so make the work safe to repeat. `reconcileAccount` should check whether an account was already reconciled for `date` before doing anything, and `listAccountsToReconcile` should return all outstanding work since the last successful run so a missed night catches up the next day.

After the next production deployment, trigger the cron job manually to confirm it reaches the web container instead of waiting for 03:00 UTC:

```bash
vercel crons run /api/cron/nightly-reconcile
```

`vercel crons` is in beta. It reads cron definitions from your deployed production project, so the cron entry must already be in production. Check the result under **Settings**, then **Cron Jobs**, then **View Logs**, and open your project's **Observability** tab and select **Workflows** to see each run, its steps, and any retries.

On the Hobby plan, cron jobs can run at most once per day, and Vercel may invoke them at any point within the scheduled hour, so the `0 3 * * *` schedule can run anytime between 03:00 and 03:59 UTC. On other plans, jobs run within the specified minute.

### 7\. Validate the preview deployment

Open the pull request's preview deployment and confirm each part of the monolith works before you retire the old workers:

- The web container responds at the preview URL, including `/healthz`
  
- Publishing a message from the preview produces a consumer log in the same preview deployment
  
- Calling the cron route manually with the preview's `CRON_SECRET` starts a workflow run
  
- The preview reads and writes only preview data, never production data
  

Run `vercel dev` from the repository root to run every service locally with the same routing and bindings. Cron schedules don't fire locally, so call `/api/cron/nightly-reconcile` directly with your local `CRON_SECRET` to test the scheduled path.

## How preview deployments isolate web, jobs, and data

Every commit to a pull request creates a preview deployment with its own URL, and every service in the project deploys atomically on that deployment's shared domain. A preview of the example monolith therefore includes the web container, the jobs service, and its queue consumers at the same commit. Services also roll back together, so an Instant Rollback returns web and jobs to a matching version. Cron jobs are the exception. An Instant Rollback doesn't update active cron jobs, which keep running on their current schedule until you disable or update them.

### Queue topics are isolated per deployment

Vercel Queues partitions topics by deployment ID by default, and in push mode it delivers messages back to the same deployment that published them. A preview deployment's consumers only process messages that the preview published, so preview jobs never consume production messages and schema changes in a pull request never break production consumers. You get a preview worker for every pull request without provisioning anything.

Vercel Workflows runs on Vercel Queues and keeps each run on the deployment that created it by default, so a workflow run started from a preview executes against that preview's code, and a new production deployment doesn't change runs already in progress.

### Cron Jobs only run in production

Vercel triggers cron jobs by sending an HTTP `GET` request to the production deployment URL, so schedules never fire on preview deployments. To test scheduled work in a preview, call the cron route yourself:

```bash
curl -H "Authorization: Bearer your_preview_cron_secret_here" \
  -H "x-vercel-protection-bypass: your_automation_bypass_secret_here" \
  https://your-preview-url.vercel.app/api/cron/nightly-reconcile
```

The `x-vercel-protection-bypass` header is only needed when Deployment Protection covers preview URLs. Create the secret under **Protection Bypass for Automation** in your project's **Deployment Protection** settings. Calls over service bindings skip the public request pipeline that Deployment Protection runs in, and queue consumers are invoked by Vercel's internal queue infrastructure rather than through a public URL, so neither needs the bypass header.

### Preview data needs its own database or branch

Preview deployments share whatever database their environment variables point to. Set the Preview environment's variables to a separate database, or use a Marketplace provider that branches the database per preview deployment. The Neon integration creates a database branch named `preview/<git-branch>` when you enable **Create Database Branch for Deployment** for Preview. Every preview deployment of that Git branch shares the same database branch, so data written by one commit's preview is still there for the next. Neon injects the branch's connection string into each preview deployment at deploy time. It overrides the Preview environment variables for that deployment and doesn't appear in your project's environment variable settings. To keep the schema in step with the code, run your migrations in the build command, for example `npx prisma migrate deploy && npm run build`. Without that separation, a preview's jobs write to the same tables as production.

### Stale previews can keep processing messages

Messages stay pinned to the deployment that published them, so an old preview keeps receiving retries until its messages are acknowledged or expire, which takes 24 hours by default. Delete preview deployments you no longer need to stop their consumers immediately.

## Run always-on workers on another platform

Some processes don't decompose into queue messages or workflow steps. Keep those workers on a platform that runs always-on containers and connect them to the Vercel-hosted web tier through a shared queue or database.

### Workers that should stay always-on

Keep a worker on Render, Railway, Amazon ECS, or Kubernetes when it:

- Holds a persistent connection it can't give up, such as a stream consumer or a long-lived socket to a third-party system
  
- Depends on local persistent disk or large in-memory state that's expensive to rebuild
  
- Needs privileged host access, custom kernels, or host networking
  
- Needs a fixed outbound IP address for allowlisting, since container images don't support Static IPs yet
  
- Runs a single unit of work that can't be split into steps and exceeds your function's `maxDuration`
  

### Connect external workers through a shared queue

An external worker needs a queue that both platforms can authenticate to. The web tier on Vercel publishes jobs, and the worker consumes them with the client and credentials it already uses on its own platform. Amazon SQS works well for this, because code running on Vercel, including container images, can assume an IAM role over OpenID Connect (OIDC) federation instead of storing AWS access keys.

Publish from the web container with the AWS SDK and the Vercel OIDC credentials provider:

```bash
npm i @aws-sdk/client-sqs @vercel/oidc-aws-credentials-provider
```
```javascript
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';

const sqs = new SQSClient({
  region: process.env.AWS_REGION,
  credentials: awsCredentialsProvider({ roleArn: process.env.AWS_ROLE_ARN }),
});

export async function enqueueTranscode(job) {
  await sqs.send(
    new SendMessageCommand({
      QueueUrl: process.env.TRANSCODE_QUEUE_URL,
      MessageBody: JSON.stringify(job),
    }),
  );
}
```

The worker on Amazon ECS, Kubernetes, Render, or Railway keeps its existing SQS consumer. Only the producer moves to Vercel.

Setting this up takes three pieces of configuration:

- **OIDC identity provider**: Register Vercel as an OpenID Connect provider in AWS IAM, then create a role whose trust policy admits your Vercel project. The [OIDC federation docs](https://vercel.com/docs/oidc) and [Migrate self-hosted Next.js and containers from AWS to Vercel](https://vercel.com/kb/guide/migrate-containers-from-aws-to-vercel) walk through the trust policy.
  
- **Pinned region**: Set `AWS_REGION` explicitly in your project's environment variables. Vercel sets `AWS_REGION` to the region the function runs in, so an unpinned client can address a queue in a region where it doesn't exist.
  
- **Per-environment values**: Set `AWS_ROLE_ARN` and `TRANSCODE_QUEUE_URL` separately for the Production and Preview environments, as described in the next section.
  

Vercel Queues also supports consumers outside Vercel through [poll mode](https://vercel.com/docs/queues/poll-mode), which is designed for long-running services, on-premise workers, and other clouds. Python projects using Celery can point an external `celery worker` at the same queues with the `vercel-poll://` broker. Every Vercel Queues API request requires a Vercel OIDC token in the `Authorization` header, so confirm how your external worker will obtain and refresh that token before choosing this path. If that isn't settled, a broker your worker already authenticates to, such as Amazon SQS or Redis, is the lower-risk choice.

### Keep your existing queue and move only the web tier

The lowest-change hybrid keeps the monolith's existing queue library. The web container on Vercel enqueues jobs into your existing broker, such as Redis from the Vercel Marketplace or a broker you already run, and the unchanged `worker` process keeps running on its current platform. Use this path to move the web tier first and convert workers later, one job type at a time.

### Preview workers in a hybrid setup

External workers don't get the per-deployment isolation that Vercel Queues provides, so separate preview traffic yourself. Point the Preview environment's `TRANSCODE_QUEUE_URL` at a preview queue, and give previews their own IAM role. Scope each role's trust policy to one project and one environment, such as `owner:[TEAM_SLUG]:project:[PROJECT_NAME]:environment:preview`, so preview deployments can never publish to the production queue. Avoid a wildcard that matches `environment:preview` across every project on your team, because anyone who can push a branch to any of those projects could then assume the role.

Then choose how preview jobs get processed:

- **Shared preview worker**: Run one external worker that consumes the preview queue for all pull requests. It's the lowest-cost option, but every preview shares one worker and one set of preview data.
  
- **Per-pull-request worker**: Create a queue and worker per pull request on the external platform. Tear both down when the pull request closes, because the external platform won't clean them up when Vercel removes the preview.
  

## Choose a Vercel-only, hybrid, or container-platform architecture

Use the process inventory from step 1 to pick the architecture. The deciding question is whether any process must stay running when there's no work to do.

| Architecture           | Fits when                                                                                                               | Previews for every pull request                                                                               |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| **Vercel-only**        | Every process is an HTTP server, a queue consumer, a multi-step job, or a scheduled task                                | Automatic for web, API, and queue consumers, with per-deployment topic isolation                              |
| **Hybrid**             | Web and API fit Vercel, but one or more workers need an always-on process                                               | Automatic for Vercel services; external workers need a shared preview worker or per-pull-request provisioning |
| **Container platform** | The monolith must stay one image with private services, attached disks, and always-on workers cloned into every preview | Handled by the container platform's preview environments                                                      |

Many teams start hybrid, moving the web tier and the easiest job types to Vercel first, then converting the remaining workers as they're rewritten. A container platform remains the better fit when most of the monolith's value sits in stateful, always-on processes. For a feature-by-feature comparison of the compute models, see [Running Docker on Vercel vs Render](https://vercel.com/kb/guide/docker-on-vercel-vs-render).

## Troubleshooting

### The deployment ignores the services configuration

Vercel switches a project into services mode when `vercel.json` defines a `services` key. Confirm that `vercel.json` sits at the repository root and contains the `services` block. In services mode, build and runtime keys such as `functions`, `buildCommand`, and `framework` aren't valid at the top level, so move them into the relevant service, then redeploy.

### The deployment builds but requests fail or time out

The container isn't listening where Vercel sends traffic. Bind the server to `0.0.0.0`, not `localhost`, and listen on `PORT` with a fallback to `80`. Confirm the startup command in `Dockerfile.vercel` starts the HTTP server rather than a worker.

### Background work stops after a few minutes

A worker loop started inside the web container dies when the instance scales down, which happens after 5 minutes without traffic in production and 30s in preview. Move that loop to a Vercel Queues consumer, a Vercel Workflow, or an external worker.

### Files or cached values disappear between requests

Container instances are stateless, and each request can land on a different instance. Move files to Vercel Blob and caches or sessions to Redis or your database.

### A job runs more than once

Vercel Queues delivers at least once, so a message can arrive again after a consumer timeout or an infrastructure event. Make handlers idempotent by recording completed work in the database, and use `idempotencyKey` when publishing to drop duplicate publishes. Cron Jobs can also invoke the same scheduled run more than once, so apply the same idempotency to work started from a cron route.

### An old deployment keeps running consumers after a rollback

Messages stay pinned to the deployment that published them, so promoting or rolling back doesn't stop the old deployment's consumers. Delete the old deployment to stop its consumers immediately, and set `maxDeliveries` on triggers to bound retries. The Observability **Query** tab can show function duration grouped by deployment to find which deployment is consuming compute.

### A request returns a 504 `FUNCTION_INVOCATION_TIMEOUT` error

The request ran longer than the function's `maxDuration`. Return quickly from HTTP routes and move the work to a queue consumer, or split it into Vercel Workflow steps that each finish within the limit.

## Next steps

- Compare the Vercel and Render compute models in [Running Docker on Vercel vs Render](https://vercel.com/kb/guide/docker-on-vercel-vs-render)
  
- Translate a full `compose.yaml` with [How Docker Compose concepts map to Vercel](https://vercel.com/kb/guide/docker-compose-concepts-on-vercel)
  
- Configure images, ports, and scale-down behavior in the [Container Images documentation](https://vercel.com/docs/functions/container-images)
  
- Route traffic and bind services in the [Vercel Services documentation](https://vercel.com/docs/services)
  
- Learn delivery, retries, and deployment isolation in [Queues concepts](https://vercel.com/docs/queues/concepts) and [Queues pricing and limits](https://vercel.com/docs/queues/pricing)
  
- Build durable multi-step jobs with [Vercel Workflows](https://vercel.com/docs/workflows)
  
- Schedule work with [Cron Jobs](https://vercel.com/docs/cron-jobs)
  
- Reach AWS queues and databases from Vercel without access keys in [Migrate self-hosted Next.js and containers from AWS to Vercel](https://vercel.com/kb/guide/migrate-containers-from-aws-to-vercel)
  
- Convert Python workers with [Celery on Vercel](https://vercel.com/docs/frameworks/backend/celery) or [Dramatiq on Vercel](https://vercel.com/docs/frameworks/backend/dramatiq)