---
title: Migrate a Python API from Heroku to Vercel
description: Move a FastAPI, Flask, or Django API from Heroku to Vercel Functions. Migrate configuration, data, workers, and scheduled jobs.
url: "https://vercel.com/kb/guide/migrate-a-python-api-from-heroku-to-vercel"
published: 2026-10-14
last_updated: 2026-10-14
authors: Ben Sabic
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

You can move a FastAPI, Flask, or Django API from Heroku to [Vercel's native Python runtime](https://vercel.com/docs/functions/runtimes/python) without a Dockerfile when its dependencies fit the supported environment. Vercel installs the application's Python dependencies and loads its entrypoint, so the `web` process in your `Procfile` no longer controls how the API runs.

The other Procfile entries need their own replacements. Move a `release` command that runs migrations into a release pipeline, adapt a Celery `worker` as a queue subscriber, and invoke Heroku Scheduler jobs through cron routes. Heroku Postgres, Heroku Key-Value Store, and other add-ons need destination services before the API can take production traffic.

## Overview

By the end, you'll have:

- Pinned the Python version your Heroku app runs in a file Vercel reads.
  
- Run your API on Vercel's native Python runtime without the Procfile `web` process or `PORT`.
  
- Replaced Heroku Postgres, Heroku Key-Value Store, and config vars with Vercel-connected services and environment variables.
  
- Moved `worker` dynos to Vercel Queues and Heroku Scheduler jobs to Vercel Cron Jobs.
  
- Moved the Procfile `release` command into CI and cut production traffic over to Vercel.
  

## Prerequisites

- Access to the API's repository and Heroku app.
  
- A Vercel account and the latest [Vercel CLI](https://vercel.com/docs/cli) installed.
  
- Python and [uv](https://docs.astral.sh/uv/getting-started/installation/) installed locally.
  
- The [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli), installed and authenticated.
  
- [PostgreSQL client tools](https://www.postgresql.org/download/) (`pg_restore` and `psql`) if you're migrating a database.
  

## Should your API move to Vercel?

Check that your API and its dependencies fit Vercel's native runtime:

- Can sessions, uploads, and other persistent state live outside the application process?
  
- Do dependencies install from Python packages and compatible wheels, or does the application need system packages?
  
- Does background work run as bounded queue tasks, or does a process need to stay running indefinitely?
  
- Do requests fit the function's duration and payload limits?
  

Check the app's buildpacks with `heroku buildpacks --app your_app_name_here`. An app that adds operating-system packages through an extra buildpack, such as an `Aptfile`, needs those packages on Vercel too. In that case, use the [Python container deployment path](https://vercel.com/kb/guide/vercel-docker-python-apps), which uses Container Images in Beta.

For an API that uses WebSockets, [Python WebSocket support is in Beta](https://vercel.com/docs/functions/websockets). Connections follow function duration limits, so clients need a reconnection strategy.

Heroku requires an [initial response within 30 seconds](https://devcenter.heroku.com/articles/request-timeout). Streaming can continue while data keeps resetting its 55-second idle timeout.

With Fluid compute, Vercel's default `maxDuration` is 300s for the entire invocation, including streaming. Hobby allows up to 300s; Pro and Enterprise allow up to 800s, with an opt-in extended-duration Beta up to 1,800s. Request and response bodies have a 4.5 MB limit. Check the [function limits](https://vercel.com/docs/functions/limitations) against your routes before cutover.

## How Heroku concepts map to Vercel

| Heroku                                                                      | Vercel                                                       |
| --------------------------------------------------------------------------- | ------------------------------------------------------------ |
| Python buildpack build                                                      | Framework detection and Python dependency installation       |
| `.python-version` or `runtime.txt`                                          | `.python-version` (Vercel doesn't read `runtime.txt`)        |
| Procfile `web` process and `PORT`                                           | Native Python application entrypoint                         |
| Gunicorn settings and `WEB_CONCURRENCY`                                     | Fluid compute concurrency and automatic scaling              |
| Extra buildpacks for system packages                                        | Container Images, in Beta                                    |
| Procfile `worker` process running Celery                                    | A subscriber on Vercel Queues, in Beta                       |
| Procfile `release` process                                                  | A migration step in continuous integration                   |
| Heroku Scheduler                                                            | A cron route configured in `vercel.json`                     |
| Config vars                                                                 | Project variables or linked Shared Environment Variables     |
| Heroku Postgres                                                             | External Postgres, including Marketplace providers           |
| Heroku Key-Value Store                                                      | External Redis-compatible storage                            |
| Add-ons                                                                     | Vercel Marketplace integrations or direct provider accounts  |
| Dyno filesystem                                                             | `/tmp` scratch space, object storage, or a database          |
| Review apps                                                                 | Preview deployments                                          |
| Pipeline promotion                                                          | A staged production deployment and `vercel promote`          |
| 30-second initial-response timeout, then a 55-second streaming idle timeout | 300s default `maxDuration`, up to 800s on Pro and Enterprise |
| `heroku logs --tail`                                                        | `vercel logs` and Runtime Logs in the dashboard              |

## Steps

Create a migration branch and keep the Heroku app running until cutover. Skip the database, worker, or scheduler steps your API doesn't need.

### 1\. Pin the Python version your app runs

Check the Python version the Heroku app runs today:

```bash
heroku run python --version --app your_app_name_here
```

The command starts a one-off dyno and prints a version such as `Python 3.13.5`. Without a version pin, [Cedar-generation apps keep the Python release line selected by their first build](https://devcenter.heroku.com/articles/python-runtimes), unless the build cache is cleared. Fir-generation apps use the default available at each build, so their Python version can change between builds.

Vercel supports [Python 3.12, 3.13, and 3.14](https://vercel.com/docs/functions/runtimes/python/python-version), with 3.12 as the default. Choose a supported version that works with your application's dependencies. Heroku also supports versions outside that range, including 3.11 and 3.15, so test any version change before moving the app to Vercel's native runtime.

Vercel reads `.python-version` but not `runtime.txt`, which Heroku [deprecated in February 2025](https://devcenter.heroku.com/changelog-items/3141). If `runtime.txt` contains `python-3.13.5`, replace it with a `.python-version` file at the Vercel project root containing:

```plaintext
3.13
```

Keep the `requires-python` constraint in `pyproject.toml` aligned with your Python version pin. Since an open lower bound such as `>=3.12` also permits 3.14, use a bounded constraint for Python 3.13:

```toml
[project]
name = "my-api"
version = "0.1.0"
requires-python = "~=3.13.0"
dependencies = [
    "fastapi>=0.117.1",
    "sqlalchemy>=2,<3",
    "psycopg[binary]>=3,<4",
    "redis>=5",
    "celery>=5.3,<6",
    "alembic>=1.13,<2",
]
```

Merge the relevant entries into your existing manifest, retaining the application's other dependencies. Keep your existing dependency workflow if it uses a manifest Vercel supports. With uv, generate the lockfile and install its dependencies:

```bash
uv lock
uv sync
```

The commands create or update `uv.lock` and install the resolved packages into `.venv`. Test with that environment, then commit `uv.lock` with the manifest changes. Vercel also accepts `requirements.txt` and Pipenv manifests; [Celery on Vercel](https://vercel.com/docs/frameworks/backend/celery) requires `pyproject.toml`.

Heroku builds from `poetry.lock`, but Vercel doesn't document it as a build input. Convert Poetry dependencies to the `[project]` format or export `requirements.txt` using [Poetry's export plugin](https://github.com/python-poetry/poetry-plugin-export). Check native dependency compatibility with your Python version, especially wheels for older `psycopg2-binary` pins.

Python function bundles have a 500 MB uncompressed limit. Exclude tests, local environments, and other unneeded files with [`excludeFiles`](https://vercel.com/docs/functions/runtimes/python); Python builds don't automatically tree-shake the application. [Large Functions](https://vercel.com/docs/functions/limitations), in Beta, raise the limit to 5 GB on projects using Fluid compute and Active CPU pricing. Check the opt-in requirements for an existing project; Large Functions don't support Secure Compute or Static IPs.

### 2\. Replace the Procfile `web` process with the Python entrypoint

For a FastAPI app with a worker and a release command, the Heroku `Procfile` commonly looks like this:

```text
web: gunicorn -k uvicorn.workers.UvicornWorker main:app
worker: celery -A tasks worker
release: alembic upgrade head
```

The `main:app` target in the `web` line already identifies the file and object Vercel needs. Steps 8 and 10 replace the `worker` and `release` lines.

Vercel loads the application object from a supported entrypoint, such as `app` in a FastAPI application's `main.py`. Keep your existing routes and add a health check:

```python
from fastapi import FastAPI

app = FastAPI()

@app.get("/health")
def health():
    return {"status": "ok"}
```

For an undetected entrypoint, set the module path in `pyproject.toml`:

```toml
[tool.vercel]
entrypoint = "my_api.server:app"
```

Use `my_api.server:app` to load `app` from `my_api/server.py`, or `main:app` for the root-level example. [Flask](https://vercel.com/docs/frameworks/backend/flask) also exports `app`; [Django](https://vercel.com/docs/frameworks/full-stack/django) uses its configured WSGI or ASGI application. Set Django's allowed hosts for the deployment.

Heroku runs Django's [`collectstatic`](https://devcenter.heroku.com/articles/django-assets) [during the build](https://devcenter.heroku.com/articles/django-assets) unless you disable it. Vercel also runs it automatically when `STATIC_ROOT` is configured, then serves the collected assets from its CDN. Check the [Django static-file instructions](https://vercel.com/docs/frameworks/full-stack/django#serving-static-assets) for your storage backend, including `django-storages` and WhiteNoise.

Gunicorn settings such as `--workers`, `--timeout`, and `WEB_CONCURRENCY` don't apply to the native deployment, which scales with Fluid compute. Keep Gunicorn or Uvicorn in your dependencies if another environment uses them. The native Vercel deployment doesn't use the Procfile or require a `PORT` setting. Keep the `Procfile` until the Heroku app is retired.

### 3\. Replace writes to the dyno filesystem

Heroku's dyno filesystem is temporary, so uploads usually belong in an external service such as Amazon S3. Check for code that still writes generated reports, cached files, or a SQLite database to the application directory.

Move uploaded files and generated artifacts to persistent object storage such as [Vercel Blob](https://vercel.com/docs/vercel-blob), or keep the existing object storage and add its credentials in step 4. Put sessions and durable application records in Redis or a database, according to their persistence requirements.

The function filesystem is read-only except for [up to 500 MB of scratch space in](https://vercel.com/docs/functions/runtimes) [`/tmp`](https://vercel.com/docs/functions/runtimes). Code that writes temporary files to the application directory on Heroku must write to `/tmp` instead. Treat that directory as temporary and local to an instance. For uploads above the function body limit, use a [direct client upload](https://vercel.com/docs/vercel-blob/client-upload) flow so the file doesn't pass through the Python API.

### 4\. Provision backing services and environment variables

#### Link the Vercel project

From the repository's application directory, link a new Vercel project:

```bash
vercel link
```

Choose the intended team and project when prompted. Commercial APIs require Pro or Enterprise because [Hobby is limited to non-commercial personal use](https://vercel.com/docs/limits/fair-use-guidelines#commercial-usage).

The CLI saves project metadata in `.vercel`, which should stay out of version control along with exported secrets and database dumps. Add the following entries to `.gitignore` before exporting configuration or pulling local variables:

```text
.vercel/
.venv/
.env*
!.env.example
*.dump
```

#### Review Heroku config vars

Export the app's config vars in shell format with the [Heroku CLI](https://github.com/heroku/cli/blob/main/docs/config.md):

```bash
umask 077
heroku config --shell --app your_app_name_here > .env.heroku
```

Review the exported entries locally before transferring them to Vercel. Remove variables that Heroku or its add-ons manage, because they point at Heroku resources and the destination services supply their own:

- `DATABASE_URL` and any `HEROKU_POSTGRESQL_*_URL` values
  
- `REDIS_URL` from Heroku Key-Value Store
  
- `HEROKU_*` variables, which Heroku [recommends excluding](https://help.heroku.com/ZLU6JD4J/how-to-copy-config-vars-from-one-app-to-another) when copying config
  
- Credentials for other add-ons you're replacing
  

For an add-on you plan to keep, such as email or monitoring, confirm the provider's migration process before changing its Heroku subscription. Create a direct account or use [Vercel Marketplace](https://vercel.com/marketplace) where supported, then test the new credentials.

Destroying a Heroku add-on deprovisions its service; detaching an attachment only removes that app's association and config vars. Check every application that shares the add-on before retiring it.

#### Add PostgreSQL with Neon

[Neon in the Vercel Marketplace](https://vercel.com/marketplace/neon) provides managed PostgreSQL databases for your project. From the linked application directory, create separate databases for Development, Preview, and Production with [`vercel integration add`](https://vercel.com/docs/cli/integration#vercel-integration-add):

```bash
vercel integration add neon --name api-development \
  --environment development --no-env-pull
vercel integration add neon --name api-preview \
  --environment preview --no-env-pull
vercel integration add neon --name api-production \
  --environment production --no-env-pull
```

The CLI installs the integration if needed, prompts for resource settings and a billing plan, and connects each database to the linked project. Scoping each resource with `--environment` keeps development and preview test writes separate from production. The `--no-env-pull` flag skips the automatic variable download; the local testing step pulls Development values when you need them.

Choose a destination PostgreSQL version compatible with the source database and enable any required extensions before restoring data. Place the database near the API's [function region](https://vercel.com/docs/functions/configuring-functions/region) to limit network latency between them.

The integration supplies connection strings for different uses:

| Environment variable    | Use                                                            |
| ----------------------- | -------------------------------------------------------------- |
| `DATABASE_URL`          | Pooled connection for application queries                      |
| `DATABASE_URL_UNPOOLED` | Direct connection for exports, restores, and schema migrations |

If you selected an integration prefix, use the resulting variable names in your application and migration commands. Leave automated Neon preview branching disabled for this rehearsal so the fixed Preview database restored in step 5 is used by the deployment in step 11.

If you choose [preview branching](https://neon.com/docs/guides/vercel-managed-integration), deploy first, identify the new branch in the Neon console, then repeat the restore and migrations against it before testing. Branch-specific connection strings are injected at build time and are not available through `vercel env pull`.

#### Connect Redis if the API uses it

Connect a Redis provider from the [Vercel Marketplace](https://vercel.com/marketplace) and obtain its TCP URL. This is typically a `rediss://` URL for TLS; REST credentials such as `KV_REST_API_URL` use a different client interface. Set `REDIS_URL` to the TCP connection string and follow the provider's TLS certificate requirements.

[Heroku Key-Value Store requires TLS](https://devcenter.heroku.com/articles/heroku-redis) on all plans, and many apps disable certificate verification with a setting such as `ssl_cert_reqs=None` to connect to it. Review that setting for the new provider rather than carrying it over.

Heroku Key-Value Store can hold cache entries, sessions, or queue state. Start with an empty replacement only if every stored value is disposable.

#### Add the remaining application secrets

Once the destination services are connected, open the Vercel project's Settings, then Environment Variables, and add the application secrets you retained from Heroku. Select the appropriate Development, Preview, and Production scopes for each value. Review entries individually so imported Heroku credentials don't overwrite integration-provided connection strings.

[Vercel's 64 KB limit](https://vercel.com/docs/environment-variables#environment-variable-size) applies to all environment-variable data in a deployment. Large values such as certificates or service-account files can exceed it, so check their size before adding them.

### 5\. Rehearse the database transfer

Use PGBackups with standard Heroku Postgres databases under moderate load, up to 20 GB. [PGBackups does not support Heroku Postgres Advanced](https://devcenter.heroku.com/articles/heroku-postgres-backups); use the appropriate [logical-backup procedure](https://devcenter.heroku.com/articles/heroku-postgres-logical-backups) for larger or heavily loaded databases.

Install the PostgreSQL client tools and use a `pg_restore` version that can read the archive. Choose a destination PostgreSQL version at least as new as the `pg_dump` version that created it, and confirm support for the required extensions. Omit SQLAlchemy driver prefixes from URLs used by PostgreSQL tools.

Rehearse the database migration into an empty, fixed Preview database with automated preview branching disabled, and reserve Production for the final transfer.

Set `DESTINATION_DATABASE_URL` in your shell to Preview's direct `DATABASE_URL_UNPOOLED` connection string. Copy it from the Neon console or, with no deployment-specific overrides, from the file saved by `vercel env pull .env.preview --environment=preview`.

If you enable automated preview branching instead, deploy first, identify the deployment's branch in the Neon console, then repeat this restore and the migrations against its direct URL before testing; `vercel env pull` does not retrieve deployment-injected branch variables.

Capture a backup of the Heroku app's primary database with [PGBackups](https://devcenter.heroku.com/articles/heroku-postgres-import-export):

```bash
umask 077
heroku pg:backups:capture DATABASE_URL --app your_app_name_here
```

Wait for successful completion and record the backup ID printed by the command. Replace `your_backup_id_here` below with that ID, then verify its source database, completion status, and capture time before downloading it. Stop if the capture failed or the details do not match the intended transfer.

```bash
HEROKU_BACKUP_ID=your_backup_id_here
heroku pg:backups:info "$HEROKU_BACKUP_ID" --app your_app_name_here
heroku pg:backups:download "$HEROKU_BACKUP_ID" --app your_app_name_here
```

The download saves that backup as `latest.dump`. After confirming the download succeeded and the destination is the intended empty database, restore it:

```bash
pg_restore --exit-on-error --no-owner --no-acl \
  --dbname="${DESTINATION_DATABASE_URL:?Set the intended destination direct URL}" latest.dump
```

Successful restoration exits with status 0. If an error mentions `heroku_ext`, inspect which source extensions use that schema and confirm the destination supports them. Prepare `heroku_ext` with `CREATE SCHEMA heroku_ext;` and install the required extensions in the appropriate schema before retrying against a fresh destination.

`--exit-on-error` can leave a partial restore, so do not retry over that data. The restore omits source owners and access grants; configure the destination application's permissions before testing it.

Compare table counts and representative records, then check constraints and sequence values. For a source reachable from your workstation, set `SOURCE_DATABASE_URL` from the Heroku app's config and compare a table named `items` using the commands below. [Private and Shield databases require an approved connection path](https://devcenter.heroku.com/articles/heroku-postgres-and-private-spaces); run source checks inside the space or through the documented connection method instead of assuming the copied URL is reachable.

```bash
SOURCE_DATABASE_URL="$(heroku config:get DATABASE_URL --app your_app_name_here)"
psql "$SOURCE_DATABASE_URL" -tAc 'SELECT count(*) FROM items;'
psql "$DESTINATION_DATABASE_URL" -tAc 'SELECT count(*) FROM items;'
```

The counts should match for unchanged test data. Because a live source can receive writes after the backup, these commands can return different counts during a rehearsal. At the final cutover, pause writers before capturing the backup and compare the restored data while they remain paused. Writes made to Heroku after the rehearsal still need to be transferred at cutover.

### 6\. Configure the Python database client

For SQLAlchemy with psycopg 3, adapt your existing database module:

```python
import os

from sqlalchemy import create_engine
from sqlalchemy.engine import make_url

database_url = make_url(os.environ["DATABASE_URL"]).set(
    drivername="postgresql+psycopg"
)
engine = create_engine(
    database_url,
    pool_size=1,
    max_overflow=0,
    pool_pre_ping=True,
)
```

Use `postgresql+psycopg` for psycopg 3, as shown above, or `postgresql+psycopg2` if the application retains psycopg2. Setting the driver this way also handles `postgres://` URLs. SQLAlchemy can't load a `postgres://` URL directly, so Heroku apps often rewrite the scheme at startup; you can remove that workaround once the database module sets the driver explicitly.

Each application instance creates its own connection pool, so keep one engine per instance and close checked-out connections after each operation. The example starts with a small pool; tune its size against the provider's connection budget and expected concurrency across all instances.

### 7\. Test the API locally with `vercel dev`

Pull the project's Development configuration:

```bash
vercel pull --environment=development
```

The restore in step 5 populated Preview, so initialize the separate Development database with the application's schema migrations and seed a known test record. Follow step 10's migration command and configuration checks using the Development database's direct `DATABASE_URL_UNPOOLED` connection string. Set that shell variable explicitly; `vercel pull` does not export it into the terminal session.

Once Development is ready, start the API:

```bash
uv run vercel dev
```

Use the local URL printed by the CLI. At its default port, the health check is:

```bash
curl --fail http://localhost:3000/health
```

For the entrypoint example, the expected body is:

```json
{"status":"ok"}
```

Exercise a database-backed route such as `/api/items/1` against a known test record. Check cache reads and writes as well as API authentication, since a passing health route alone doesn't test those dependencies.

### 8\. Move `worker` dynos to Vercel Queues

Through the supported Celery integration, a Procfile `worker` running `celery -A tasks worker` can become a queue-triggered subscriber. Classify each non-web process before migrating it. Queue tasks have a supported path; a `clock` process or other long-running loop needs a different design, such as a cron route, or a dyno retained on Heroku.

Vercel Queues is in Beta, and the [Celery integration](https://vercel.com/docs/frameworks/backend/celery) requires Vercel CLI 58.9.0 or later. For an existing Celery application, change its initialization in `tasks.py` and retain the task definitions below it:

```python
from celery import Celery

app = Celery(
    "my-api",
    broker="vercel://",
    backend="vercel-runtime-cache://",
)
app.conf.task_acks_late = True
```

Declare every queue your tasks use in `task_queues` or `task_default_queue` before the build imports the Celery application. Queues named only in `task_routes` are not discovered, so no subscriber will receive their messages. Check the [queue declaration instructions](https://vercel.com/docs/frameworks/backend/celery#splitting-queues-across-functions) if the Heroku worker consumes custom queues.

Then create `worker.py` at the project root to export the Celery application:

```python
from tasks import app

__all__ = ["app"]
```

Merge these settings into `pyproject.toml` with your actual web entrypoint. Celery requires an explicit web entrypoint, including for a root-level `main.py`. If step 2 already added `[tool.vercel]`, keep it and append the subscriber table:

```toml
[tool.vercel]
entrypoint = "main:app"

[[tool.vercel.subscribers]]
entrypoint = "worker:app"
```

Restart `vercel dev` to load the new configuration and run the web application and subscriber together. Then call the route that enqueues work, such as `POST /api/reports`, and verify the task's completed result or database side effect.

Tasks can be delivered more than once, so use an idempotent write strategy such as a unique report identifier. Late acknowledgments protect against timeout or crash loss; configure explicit retries for application exceptions. Store task results that need to persist in Postgres or a supported durable backend because the Runtime Cache result backend is ephemeral.

Replace `celery beat` with a cron route that enqueues work. Celery publishes `countdown` and `eta` tasks immediately, then waits inside the subscriber invocation, so keep delays and retry backoff short enough to fit its duration limit. For Dramatiq, follow the [Dramatiq configuration](https://vercel.com/docs/frameworks/backend/dramatiq). Other queue libraries, such as RQ, need their own adaptation or a retained Heroku worker.

### 9\. Replace Heroku Scheduler jobs with cron routes

To replace a Heroku Scheduler job that runs `python -m jobs.cleanup` in a one-off dyno, expose the cleanup function through an HTTP route on Vercel.

Refactor `jobs/cleanup.py` so it exports a `run()` function containing the existing job logic. Retain a `main` guard while Heroku Scheduler still runs the module. This example assumes `run()` returns the number of deleted records. Add the following imports and route to your existing FastAPI `main.py`:

```python
import hmac
import os

from fastapi import Header, HTTPException
from jobs.cleanup import run as cleanup

@app.get("/api/cron/cleanup")
def cleanup_job(authorization: str | None = Header(default=None)):
    secret = os.environ.get("CRON_SECRET")
    if not secret:
        raise HTTPException(status_code=503, detail="Cron secret is not configured")
    expected = f"Bearer {secret}"
    if not authorization or not hmac.compare_digest(
        authorization.encode(), expected.encode()
    ):
        raise HTTPException(status_code=401, detail="Unauthorized")
    if os.environ.get("JOBS_ENABLED") != "true":
        return {"status": "disabled"}
    return {"status": "ok", "deleted": cleanup()}
```

Set a random `CRON_SECRET` in Vercel's environment variables and leave `JOBS_ENABLED=false` in Production until the scheduler handoff. This example introduces `JOBS_ENABLED` as an application variable that prevents the route from running cleanup early.

Merge the schedule into `vercel.json`:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "crons": [
    {"path": "/api/cron/cleanup", "schedule": "0 3 * * *"}
  ]
}
```

[Heroku Scheduler](https://devcenter.heroku.com/articles/scheduler) runs jobs every 10 minutes, every hour, or every day at a time in UTC. Translate each job to a cron expression:

| Heroku Scheduler frequency | Cron expression | Vercel plan       |
| -------------------------- | --------------- | ----------------- |
| Every 10 minutes           | `*/10 * * * *`  | Pro or Enterprise |
| Every hour at :30          | `30 * * * *`    | Pro or Enterprise |
| Every day at 03:00 UTC     | `0 3 * * *`     | All plans         |

[Vercel Cron Jobs](https://vercel.com/docs/cron-jobs/manage-cron-jobs) send an HTTP GET to the production route. When `CRON_SECRET` is set, the request includes it as a bearer token in the `Authorization` header, which the route above checks. Schedules use UTC, like Heroku Scheduler's daily jobs.

On Hobby, a daily job runs within the scheduled hour, and more frequent schedules fail deployment. Pro and Enterprise support per-minute schedules. All plans allow [100 cron jobs per project](https://vercel.com/docs/cron-jobs/usage-and-pricing).

Make the cleanup operation safe to repeat, and use a lock if overlapping runs would cause problems. Cron delivery is best effort, and failed invocations aren't retried automatically, which is similar to Heroku Scheduler's expected but not guaranteed execution. Monitor failures and provide a way to rerun missed work.

Test missing and incorrect credentials for a 401 response. In an isolated test environment with `JOBS_ENABLED=true`, a valid request should return `status: ok` and the deletion count.

### 10\. Move the Procfile `release` command to CI

Heroku runs the `release` process before a new release goes live, and [doesn't deploy the release if that command fails](https://devcenter.heroku.com/articles/release-phase). Preserve that ordering by moving schema migrations into the release pipeline. Vercel doesn't read the Procfile.

Run migrations from continuous integration before promotion. Rehearse against the intended Preview database now; run the Production migration only after the final restore in step 12. The migration job must check out the deployment's commit, install the pinned Python version and uv, and run from the application root.

Bind a CI secret named `DATABASE_URL_UNPOOLED` to the intended destination's direct URL. For a local rehearsal, set that shell variable explicitly because `vercel pull` does not export it into your terminal session. If Alembic reads `DATABASE_URL`, use:

```bash
: "${DATABASE_URL_UNPOOLED:?Set the intended direct database URL}" && \
  uv sync --locked && \
  DATABASE_URL="$DATABASE_URL_UNPOOLED" uv run --locked alembic upgrade head
```

The command stops if the variable is missing, the lockfile needs updating, dependency installation fails, or the migration fails. Keep promotion dependent on its successful exit.

If Alembic reads a URL from another setting, update `alembic/env.py` to read the intended variable; setting a shell variable alone won't change that configuration. Apply the explicit SQLAlchemy driver selection in the migration configuration too. For Django, run `python manage.py migrate` with its database settings pointing to the same intended destination.

Keep schema changes compatible with the old application while it is still running. Avoid migrations in request handlers, application startup, or a preview build that can reach production data.

### 11\. Verify a preview deployment

Before deploying, confirm whether this Vercel project already has a successful Production deployment. The first deployment of a new project targets Production, even without `--prod`.

If the project has no successful Production deployment, temporarily use only isolated test services for Production. Keep `JOBS_ENABLED=false` and disable startup writes and task producers before deploying without assigning domains:

```bash
vercel deploy --prod --skip-domain
```

After it succeeds, deploy the migration branch to Preview. Projects that already have a successful Production deployment can skip the initial deployment above. Keep the final production service connections for step 12.

```bash
PREVIEW_URL="$(vercel deploy)"
```

`vercel deploy` prints the deployment URL to standard output, so this command stores it in `PREVIEW_URL`. Inspect that deployment and test the health route:

```bash
vercel inspect "$PREVIEW_URL" --logs
vercel curl /health --deployment "$PREVIEW_URL"
vercel logs --deployment "$PREVIEW_URL" --query 'level:error'
```

The build log should show your pinned Python minor version and successful dependency installation. Confirm in the dashboard that the deployment uses Preview and its intended database before testing writes. The health route should return 200. Authorized project members can use `vercel curl` to bypass Deployment Protection; protected API routes still require application credentials.

Verify authenticated routes and representative database writes on preview data. Enqueue a task and wait for its result. Call the cron route manually with test credentials; scheduled invocation only happens in Production. Confirm that long requests and representative uploads fit the configured limits.

If a route needs longer than the 300s default, merge a function entry keyed by the resolved entrypoint file into `vercel.json`. This example uses the 800s maximum available on Pro and Enterprise:

```json
{
  "functions": {
    "main.py": {"maxDuration": 800}
  }
}
```

Use your actual filename and retain the `crons` entry. Workarounds that existed only to stay under Heroku's 30-second limit, such as polling endpoints for long reports, can stay in place; simplify them separately once the migration is stable. Test any FastAPI lifespan cleanup that the API relies on; [shutdown has a 500 ms window](https://vercel.com/docs/frameworks/backend/fastapi), so durable work shouldn't depend on a long shutdown handler.

### 12\. Cut over production traffic

Set Production variables to the final backing services and keep `JOBS_ENABLED=false`. Before creating a staged deployment, block writes and task production through a maintenance gate or equivalent control, and disable startup side effects.

`--skip-domain` leaves the deployment reachable at its generated URL with Production configuration, so withholding the custom domain does not prevent writes. Keep these controls active through the final restore and read-only checks:

```bash
PRODUCTION_DEPLOYMENT_URL="$(vercel deploy --prod --skip-domain)"
```

The command stores the staged deployment's URL in `PRODUCTION_DEPLOYMENT_URL` for promotion after the final data transfer.

Schedule a write pause for the final backup and restore:

1. Turn on [maintenance mode](https://devcenter.heroku.com/articles/maintenance-mode) with `heroku maintenance:on --app your_app_name_here`, then let in-flight requests finish. Heroku's router blocks new HTTP requests, but worker dynos and Heroku Scheduler jobs continue running.
   
   Fir-generation apps need an application-level write pause because maintenance mode is unavailable. Confirm that the chosen control blocks every source of new application writes before taking the final backup.
   
2. Disable Heroku Scheduler jobs and stop task producers, including any retained clock process. Wait for already-running Scheduler jobs and other one-off jobs that write data to finish.
   
   Let worker dynos finish pending, delayed, or retrying tasks, then scale them to zero with `heroku ps:scale worker=0 --app your_app_name_here`. Confirm that all source writers are idle before taking the final backup in item 3. Replay retained work only against the chosen destination.
   
3. Set `DESTINATION_DATABASE_URL` to the intended Production database's `DATABASE_URL_UNPOOLED` value. Confirm its identity and that it contains no application data. If a rehearsal or staged deployment wrote to it, preserve anything needed and prepare a fresh destination before proceeding.
   
   If the destination URL changes, update Production connections and repeat the staged-deployment command so `PRODUCTION_DEPLOYMENT_URL` refers to a deployment using that destination. Capture, verify, download, and restore the final backup with step 5's commands. Run the required migrations only after the restore succeeds, and stop the cutover if restoration or migration fails.
   
4. Synchronize files added, changed, or deleted since the rehearsal. Skip this transfer if both deployments already use the same object storage.
   
5. Complete the Redis handoff while writers remain paused. Transfer retained session data and durable keys through the destination provider's supported process and check their expiry times. If the migration intentionally invalidates sessions, plan for users to sign in again.
   
   Rebuild disposable caches as needed, and reconcile queue state through the worker drain or replay plan from item 2 before enabling the new producers.
   

With application writes and task producers still blocked, verify the final database, Redis state, and files and test read-only routes against the staged deployment. Preserve the Heroku write pause. Promote only after those checks and the migration job succeed:

```bash
vercel promote "$PRODUCTION_DEPLOYMENT_URL"
```

After promotion, remove the Vercel application's write barrier and confirm an authenticated write reaches the final database. Redeploy if an environment variable controls the barrier.

[Add the custom domain](https://vercel.com/docs/domains/working-with-domains/add-a-domain) to the Vercel project and replace the DNS records that point to Heroku with the records shown for that domain. Confirm HTTPS and API behavior through the domain. Update allowed origins, callback URLs, and webhook destinations that contain the `herokuapp.com` hostname.

Keep Heroku's write pause active while cached DNS records still send traffic there. Use maintenance mode on Cedar or the application-level gate on Fir so neither can write to the old database.

After Heroku Scheduler is disabled and its work has finished, set `JOBS_ENABLED=true` for Production and redeploy so the new value takes effect. Confirm one scheduled execution and check for duplicate work.

### 13\. Retire unused Heroku resources after the rollback window

Keep the Heroku app and its add-ons available for the rollback window you chose before cutover. Monitor errors, database connections, and task completion under production traffic before removing unused resources.

> **Warning:**
> 
> If Vercel has accepted new writes, sending traffic back to the old application and database can lose those changes. Reconcile data or point the compatible old application at the authoritative database before reverting traffic. Vercel's application rollback doesn't reverse database migrations or restore data.

Dynos continue to accrue billing hours in maintenance mode. After the rollback window, retain the required backups, then scale down dynos, remove add-ons you've replaced, and delete the Heroku app when nothing depends on it. Delete local secret exports and database backups when they are no longer needed.

## What doesn't map from Heroku

Processes that must run continuously, such as a `clock` process that schedules work in a loop, need to be redesigned around cron routes or queue tasks, or stay on Heroku. One-off dynos started with `heroku run` have no direct equivalent; run maintenance scripts locally or from CI against the intended database.

Heroku add-ons inject credentials into the Heroku app and bill through Heroku. Arrange a Vercel Marketplace integration or a direct provider account for each service before retiring its Heroku subscription.

Review apps configured through `app.json` don't carry over. With Git connected and automatic deployments enabled, pushes to non-production branches create Preview deployments. Configure Preview's backing services and any variables that differ by branch.

## Troubleshooting

### The build uses an unexpected Python version

Check that `.python-version` is at the configured project root and matches any `requires-python` constraint. Vercel doesn't read `runtime.txt`, so a version pinned only there is ignored. Inspect the build log after redeploying.

### A package fails to install or import

Check that the selected Python version matches the application's tested dependencies. Without a compatible wheel, installation may require a native build. Confirm the dependency is declared in the manifest Vercel uses, especially if Heroku built from `poetry.lock`. For packages that need operating-system libraries the app previously installed through an extra buildpack, use the container path.

### SQLAlchemy can't load the `postgres` dialect

An error such as `NoSuchModuleError: Can't load plugin: sqlalchemy.dialects:postgres` means a `postgres://` URL reached SQLAlchemy without a driver. Set the driver explicitly as shown in step 6, or check that the deployment uses the destination provider's `DATABASE_URL` rather than a copied Heroku value.

### Every route returns 404

Confirm the Vercel project's root directory contains the dependency manifest and entrypoint. Check the exported application name and custom `tool.vercel.entrypoint`, then inspect the deployment's detected framework.

### Requests exceed the payload or duration limit

For a 413 `FUNCTION_PAYLOAD_TOO_LARGE`, send the file directly to object storage. For a 504 `FUNCTION_INVOCATION_TIMEOUT`, inspect slow database or external-service calls before increasing `maxDuration` within the plan's limit. Divide work that still exceeds the limit into bounded tasks.

### A cron job doesn't run in preview

Vercel schedules run against Production. Test the handler manually on the preview deployment, then check the active production schedule, `CRON_SECRET`, and application gate during cutover.

## Next steps

- [Python runtime reference](https://vercel.com/docs/functions/runtimes/python)
  
- [Deploy a FastAPI app on Vercel](https://vercel.com/docs/frameworks/backend/fastapi)
  
- [Run Celery on Vercel](https://vercel.com/docs/frameworks/backend/celery)
  
- [Vercel Cron Jobs](https://vercel.com/docs/cron-jobs)
  
- [Heroku vs Vercel: comparison guide](https://vercel.com/i/heroku-vs-vercel-comparison-guide)
  
- [Deploy Python apps on Vercel using Docker](https://vercel.com/kb/guide/vercel-docker-python-apps)
  

## Frequently asked questions

### Is Heroku shutting down?

Heroku's [February 6, 2026 announcement](https://www.heroku.com/blog/an-update-on-heroku/) described a shift to sustaining engineering, not a shutdown. The platform remains supported. Heroku said credit-card customers would see no change to pricing, billing, or service, and existing Enterprise contracts would be honored.

### What happens to my Procfile?

Vercel's native Python deployment loads an application entrypoint instead of running the Heroku `web` command. Configure Celery workers as queue subscribers and move release migrations into a pipeline that gates promotion on success. Keep the `Procfile` while the source application still runs on Heroku.

### Can my worker dyno move to Vercel?

Celery or Dramatiq tasks can run as subscribers on Vercel Queues, which is in Beta. Check task duration, retries, and result persistence, and plan how pending broker messages will finish before stopping the Heroku dyno. Processes that must run continuously need a different design or a retained host.

### How do I export standard Heroku Postgres?

Capture a backup with `heroku pg:backups:capture`, verify its ID and completion status, and download that backup with `heroku pg:backups:download`. Restore it into an empty destination with `pg_restore` and verify the data. Repeat under a write pause at cutover to include later writes.

PGBackups is intended for moderately loaded databases up to 20 GB and does not support Heroku Postgres Advanced.

### What replaces Heroku Scheduler?

Vercel Cron Jobs use schedules in `vercel.json` to invoke an authenticated HTTP route that performs or enqueues work. Schedules use UTC. Daily jobs work on every plan; jobs every 10 minutes or every hour need Pro or Enterprise. Disable the Heroku job and let its work finish before enabling the replacement.

### Do I need to change my Gunicorn settings?

Gunicorn flags such as `--workers` and `WEB_CONCURRENCY` do not control Vercel's native Python deployment. Fluid compute handles concurrency and scaling; review the function's duration and database connection usage instead. Keep Gunicorn if local development or a retained deployment still uses it.

## More Vercel Functions guides

- [Migrate a Python API from Render to Vercel](/kb/guide/migrate-a-python-api-from-render-to-vercel): Move a FastAPI, Flask, or Django API from Render to Vercel Functions. Migrate configuration, data, workers, and scheduled jobs.
- [Migrate a Python API from Railway to Vercel](/kb/guide/migrate-a-python-api-from-railway-to-vercel): Move a FastAPI, Flask, or Django API from Railway to Vercel Functions. Migrate configuration, data, workers, and scheduled jobs.
- [Build Figma-style multiplayer cursors with WebSockets on Vercel](/kb/guide/real-time-board-nextjs-fastapi): Learn how to build Figma-style multiplayer cursors with Next.js and FastAPI, kept consistent across multiple Vercel Function instances using WebSockets and Redis.