---
title: Deploy PHP on Vercel with Docker
description: Build a PHP application with FrankenPHP and Docker, then deploy it to Vercel Functions with managed configuration, storage, and preview deployments.
url: /kb/guide/deploy-php-on-vercel-with-docker
canonical_url: "https://vercel.com/kb/guide/deploy-php-on-vercel-with-docker"
published: 2026-08-11
last_updated: 2026-08-11
authors: Anshuman Bhardwaj
related:
  - /docs/container-registry
  - /docs/cli
  - /docs/vercel-blob
  - /docs/global-config
  - /docs/deployments/environments
  - /docs/services
  - /docs/fluid-compute
  - /docs/cron-jobs
  - /docs/workflows
  - /kb/guide/docker
  - /docs/project-configuration/vercel-json
  - /docs/functions/container-images
  - /docs/frameworks
install_vercel_plugin: npx plugins add vercel/vercel-plugin
---

PHP applications need both a web server and a PHP runtime to receive HTTP traffic, and FrankenPHP combines the two in a single production server built on Caddy. Add a `Dockerfile.vercel` and set the service `runtime` to `container`. Vercel builds the image, stores it in the [Vercel Container Registry](https://vercel.com/docs/container-registry), and serves it from a Function that autoscales with traffic and scales to zero when idle.

This guide deploys a minimal PHP JSON API, configures FrankenPHP to listen on Vercel's `PORT`, and explains how to use durable external storage instead of the container file system.

## Prerequisites

- A [Vercel account](https://vercel.com)
  
- Docker Desktop or a running Docker daemon
  
- [Vercel CLI](https://vercel.com/docs/cli) (`npm install -g vercel`)
  

## How it works

The project has these parts:

- `public/index.php` returns JSON for `/` and `/health`.
  
- `Caddyfile` serves the public directory and binds FrankenPHP to `$PORT`.
  
- `composer.lock` pins PHP dependencies if the application adds any.
  
- `Dockerfile.vercel` installs production dependencies and copies the application into a FrankenPHP runtime.
  
- `vercel.json` declares a container service and routes requests to it.
  

## Steps

### 1\. Initialize the project

Create the project directory and the document root:

```bash
mkdir vercel-docker-php
cd vercel-docker-php
mkdir public
```

Generate the Composer manifests. The image contains both `composer.json` and `composer.lock`, so create them now even if the application has no dependencies yet:

```bash
composer init --no-interaction --name=vercel/docker-php
composer update
```

You should now have a minimal `composer.json`. Add production packages to `require` as the application grows, and commit `composer.lock`, so builds resolve the same versions:

```json
{
  "name": "vercel/docker-php",
  "require": {}
}
```

Now, for the front controller, create a new file called `public/index.php`:

```php
<?php

declare(strict_types=1);

header('Content-Type: application/json');
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

if ($path === '/') {
    echo json_encode(['message' => 'Hello from PHP on Vercel']);
    exit;
}

if ($path === '/health') {
    echo json_encode(['status' => 'ok']);
    exit;
}

http_response_code(404);
echo json_encode(['error' => 'not found']);
```

Create `Caddyfile` in the project root:

```plaintext
:{$PORT:80} {
    root * /app/public
    encode zstd gzip
    php_server
}
```

The site address reads `PORT` and falls back to 80, which matches Vercel's default container port. The leading colon listens on all container interfaces rather than only `localhost`, and because the address has no hostname, Caddy does not attempt to provision its own certificates for traffic that Vercel already terminates at the edge.

The project now contains the following files, with the two configuration files from the next steps still to come:

```plaintext
vercel-docker-php
├── Caddyfile
├── composer.json
├── composer.lock
└── public
    └── index.php
```

### 2\. Add `Dockerfile.vercel`

Create the `Dockerfile.vercel` file in the project root:

```dockerfile
FROM composer:2 AS dependencies
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-interaction --no-progress --optimize-autoloader

FROM dunglas/frankenphp:1-php8.4-alpine
WORKDIR /app
COPY --from=dependencies /app/vendor ./vendor
COPY Caddyfile /etc/caddy/Caddyfile
COPY public ./public
ENV PORT=80
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
```

Copying `composer.json` and `composer.lock` before the application code lets Docker reuse the dependency layer. Composer stays in the first stage, so the final image contains only FrankenPHP, production packages, and application files.

The final stage runs as a non-root user. Passing `--config` explicitly keeps the Dockerfile independent of where a given image version looks for its default `Caddyfile`.

### 3\. Add `vercel.json`

Create a `vercel.json` file to configure your project:

```json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "services": {
    "api": {
      "root": ".",
      "entrypoint": "Dockerfile.vercel",
      "runtime": "container"
    }
  },
  "rewrites": [
    { "source": "/(.*)", "destination": { "service": "api" } }
  ]
}
```

The service selects the container runtime and points to the Dockerfile. The catch-all rewrite sends every public path to the FrankenPHP service.

### 4\. Run locally

```bash
vercel dev -L
```

Test the URL printed by the CLI:

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

### 5\. Deploy to Vercel

```bash
vercel login
vercel deploy --prod
```

For Git deployments, import the repository in Vercel and keep `Dockerfile.vercel`, `Caddyfile`, and `vercel.json` at the configured project root.

## Extend your application

You have deployed a PHP service with a production web server. You can adapt this container model for Laravel, Symfony, or another framework. Copy the complete application into the final image, install the framework’s required PHP extensions, run its production build or cache commands, and keep the document root pointed at its public directory.

### Environment variables

Add variables in **Vercel Dashboard > Project Settings** or with `vercel env add NAME`. Pull development values with `vercel env pull`, then read them with `getenv()` or your framework's configuration layer. Do not commit `.env` files or copy them into the image.

### Database and storage

The container file system is not persistent because it runs on Vercel Functions, which scale down when idle.

| Use case                      | Recommended service                                                                                                  |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Files and uploads             | [Vercel Blob](https://vercel.com/docs/vercel-blob)                                                                   |
| Relational data               | [Marketplace Postgres integration](https://vercel.com/marketplace/category/storage?category=storage&search=postgres) |
| Sessions and caching          | [Marketplace Redis integration](https://vercel.com/marketplace/category/storage?category=storage&search=redis)       |
| Frequently read configuration | [Vercel Global Config](https://vercel.com/docs/global-config)                                                        |

Store PHP sessions outside the file system. Keep persistent database connections and worker counts conservative, as each Vercel Function instance creates additional connections.

### Iterate with preview deployments

A connected repository receives a unique [Preview Deployment](https://vercel.com/docs/deployments/environments) for each branch push or pull request. Run `vercel deploy` without `--prod` for a disposable CLI preview.

### Scale automatically with Fluid compute

[Vercel Services](https://vercel.com/docs/services) run on [Fluid compute](https://vercel.com/docs/fluid-compute) by default. Instances handle concurrent traffic and scale to zero when idle. Active CPU billing pauses during I/O waits and when no requests run; memory and invocation charges still apply.

## Troubleshooting

### The deployment builds but requests return 502

**Cause:** Caddy is listening on a hardcoded port or only on localhost.

**Fix:** Keep `:{$PORT:80}` in the Caddyfile. A Docker `EXPOSE` instruction does not configure the port Vercel routes to.

### Routes other than `/` return 404

**Cause:** The document root or front-controller routing does not send the request to `public/index.php`.

**Fix:** Keep `root * /app/public` and `php_server`. For a framework, copy its actual public directory and verify rewrite behavior using the final image.

### Changes to PHP code are not reflected

**Cause:** OPcache or a framework cache contains stale production artifacts, or the updated source was not copied into the final image.

**Fix:** Ensure the Dockerfile copies the changed directory after dependency installation. Build framework caches during the image build and regenerate them on every deployment.

### Queue workers or scheduled commands stop

**Cause:** Long-running PHP workers and in-process schedulers assume the container remains alive.

**Fix:** Use [Vercel Cron](https://vercel.com/docs/cron-jobs) to call HTTP endpoints and [Workflow](https://vercel.com/docs/workflows) for durable work. Let FrankenPHP handle `SIGTERM` and finish active requests.

## Next steps

- Learn how [Vercel Services](https://vercel.com/docs/services) routes traffic
  
- Learn how to run [Docker on Vercel](https://vercel.com/kb/guide/docker)
  
- Review the [vercel.json reference](https://vercel.com/docs/project-configuration/vercel-json)
  
- Read the [container images documentation](https://vercel.com/docs/functions/container-images)
  
- Explore the [Vercel Frameworks documentation](https://vercel.com/docs/frameworks)