Skip to content
Docs

Deploy PHP on Vercel with Docker

Build a PHP application with FrankenPHP and Docker, then deploy it to Vercel Functions with managed configuration, storage, and preview deployments.

Anshuman BhardwajContent Engineer

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, 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.

Copy link to headingPrerequisites

Copy link to headingHow 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.

Copy link to headingSteps

Copy link to heading1. Initialize the project

Create the project directory and the document root:

Terminal
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:

Terminal
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:

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

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

public/index.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:

Caddyfile
:{$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:

Project structure
vercel-docker-php
├── Caddyfile
├── composer.json
├── composer.lock
└── public
└── index.php

Copy link to heading2. Add Dockerfile.vercel

Create the Dockerfile.vercel file in the project root:

Dockerfile.vercel
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.

Copy link to heading3. Add vercel.json

Create a vercel.json file to configure your project:

vercel.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.

Copy link to heading4. Run locally

Terminal
vercel dev -L

Test the URL printed by the CLI:

Terminal
curl http://localhost:3000/
curl http://localhost:3000/health

Copy link to heading5. Deploy to Vercel

Terminal
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.

Copy link to headingExtend 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.

Copy link to headingEnvironment 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.

Copy link to headingDatabase and storage

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

Use caseRecommended service
Files and uploadsVercel Blob
Relational dataMarketplace Postgres integration
Sessions and cachingMarketplace Redis integration
Frequently read configurationVercel 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.

Copy link to headingIterate with preview deployments

A connected repository receives a unique Preview Deployment for each branch push or pull request. Run vercel deploy without --prod for a disposable CLI preview.

Copy link to headingScale automatically with Fluid compute

Vercel Services run on 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.

Copy link to headingTroubleshooting

Copy link to headingThe 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.

Copy link to headingRoutes 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.

Copy link to headingChanges 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.

Copy link to headingQueue workers or scheduled commands stop

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

Fix: Use Vercel Cron to call HTTP endpoints and Queues for durable work. Let FrankenPHP handle SIGTERM and finish active requests.

Copy link to headingNext steps

More Vercel Services guides