Skip to content
Docs

Deploy Laravel on Vercel with Docker

Build a Laravel application with FrankenPHP and Docker, then deploy it to Vercel with production configuration, external storage, and preview deployments.

Anshuman BhardwajContent Engineer

Laravel can run in a container-backed Vercel Function. FrankenPHP combines the PHP runtime with a production web server built on Caddy. Vercel builds the application into an image, stores it in Vercel Container Registry, and scales Function instances with traffic.

This guide deploys a stateless Laravel application, routes requests through public/index.php, and explains how to supply configuration and store durable state.

Copy link to headingPrerequisites

  • A Vercel account
  • Docker Desktop or a running Docker daemon
  • Vercel CLI (npm install -g vercel)
  • Composer 2, when creating the project outside Docker

Copy link to headingHow it works

The project has these deployment-specific files:

  • routes/web.php returns a JSON response from Laravel and exposes /health for deployment smoke tests.
  • Caddyfile exposes only public and sends application routes to public/index.php.
  • Dockerfile.vercel installs production dependencies and copies the complete Laravel application into FrankenPHP.
  • vercel.json declares the container service and routes requests to it.

Copy link to headingSteps

Copy link to heading1. Create the Laravel application

Create a Laravel project:

Terminal
composer create-project laravel/laravel vercel-docker-laravel
cd vercel-docker-laravel

Replace the default route in routes/web.php with a response that identifies the running framework:

routes/web.php
<?php
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Route;
Route::get('/', static function (): JsonResponse {
return response()->json([
'framework' => 'Laravel',
'message' => 'Hello from Laravel and FrankenPHP on Vercel',
]);
});

Add a JSON health endpoint after the / route:

routes/web.php
Route::get('/health', static function (): JsonResponse {
return response()->json(['status' => 'ok']);
});

Laravel also registers /up as its default framework health route in bootstrap/app.php.

Copy link to heading2. Configure FrankenPHP

Create Caddyfile in the project root:

Caddyfile
:{$PORT:80} {
root * /app/public
encode zstd gzip
php_server {
try_files {path} index.php
}
}

The site address reads the PORT environment variable and defaults to port 80. The document root prevents requests from accessing Laravel configuration or source files outside public.

Copy link to heading3. Add Dockerfile.vercel

Create the Dockerfile.vercel file in the project root:

Dockerfile.vercel
FROM dunglas/frankenphp:1-php8.4-bookworm AS base
RUN cp "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" \
&& install-php-extensions intl zip
WORKDIR /app
FROM base AS dependencies
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--no-progress \
--no-scripts \
--optimize-autoloader \
--prefer-dist
COPY . .
RUN mkdir -p \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
bootstrap/cache \
&& composer dump-autoload --no-dev --classmap-authoritative
FROM base AS runtime
COPY --from=dependencies --chown=www-data:www-data /app /app
COPY --chown=www-data:www-data Caddyfile /etc/frankenphp/Caddyfile
RUN setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/frankenphp \
&& chown -R www-data:www-data /config/caddy /data/caddy
ENV PORT=80 \
APP_ENV=production \
APP_DEBUG=false \
LOG_CHANNEL=stderr \
CACHE_STORE=array \
SESSION_DRIVER=array \
QUEUE_CONNECTION=sync
USER www-data
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]

The initial Composer installation skips scripts to preserve dependency-layer caching. After the application is copied, composer dump-autoload runs Laravel’s post-autoload-dump hooks, including package discovery.

The array cache and session drivers keep the example independent of a database. Configure shared external stores before using sessions, caches, or queues in a production application.

QUEUE_CONNECTION=sync executes jobs during the request and is suitable only for this minimal example. It does not provide asynchronous background processing.

Copy link to heading4. Exclude local files from the image

Create a .dockerignore file to specify excluded files:

.dockerignore
.git
.env
.env.*
!.env.example
database/database.sqlite
node_modules
tests
vendor

The image must not contain .env files. Vercel injects the deployment’s environment variables when the container starts.

Copy link to heading5. Add vercel.json

Create a vercel.json file to configure your project:

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

The catch-all rewrite sends every public path to the Laravel service.

Copy link to heading6. Run locally

Laravel uses the APP_KEY environment variable to encrypt and authenticate cookies and session data.

Generate a local application key:

Terminal
php artisan key:generate --show

Store the displayed value in an uncommitted .env.local file:

Terminal
APP_KEY=base64:your_generated_app_key

The -L flag does not automatically inject .env.local into a container service. Export the file into the current shell, then start the application through Vercel's local container runtime:

Terminal
set -a
source .env.local
set +a
vercel dev -L --listen 8080

Test both routes:

Terminal
curl http://localhost:8080/
curl --fail http://localhost:8080/health

Copy link to heading7. Configure the application key

Add the generated key as an environment variable in your Vercel project:

Terminal
vercel env add APP_KEY

Use a stable APP_KEY within each environment, so redeployments can decrypt existing cookies and encrypted data. Do not reuse the production key in Preview or Development unless those environments intentionally share encrypted data. Keep APP_DEBUG=false in production.

Copy link to heading8. Deploy to Vercel

Once ready, deploy your project to Vercel with a single command:

Terminal
vercel deploy --prod

Run vercel deploy without --prod to create a Preview Deployment.

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

Copy link to headingConfigure production services

Copy link to headingDatabase, sessions, and cache

Add database credentials as Vercel environment variables and install the matching PHP extension in Dockerfile.vercel, such as pdo_pgsql or pdo_mysql. Use an external database or Redis-compatible service for the state that must be shared across instances. Because Fluid compute can process concurrent requests in one instance and scale across multiple instances, use database connection pooling where available and size connection limits for aggregate concurrency.

Run database migrations as a separate release or continuous integration step:

Terminal
php artisan migrate --force

Do not run migrations at container startup because multiple instances may start concurrently.

Copy link to headingFiles and logs

The container file system is not durable. Store uploads in an external object store such as Vercel Blob. LOG_CHANNEL=stderr sends application logs to Vercel runtime logs.

Copy link to headingDeployment optimization

Laravel’s php artisan optimize command caches configuration, events, routes, and views. However, configuration caching resolves environment variables when the command runs. Because Vercel provides values such as APP_KEY and database credentials at runtime, do not run the full command while building the container image.

Caches generated during the Docker build become immutable image contents, so every instance created from that deployment receives them. Files written while an instance is running may remain temporarily available to that instance, but they are not shared with other instances and disappear when the instance is recycled or the deployment changes.

You can cache components that do not depend on runtime environment variables:

Dockerfile.vercel
RUN php artisan event:cache \
&& php artisan view:cache

You can also run php artisan route:cache. Laravel 13 can cache the closure routes in this example, although closures that capture unserializable values may still fail. Regenerate the route cache whenever routes change.

Avoid generating these caches from the container entrypoint. That would repeat the work whenever a new instance starts and add to its startup time. Keep the configuration uncached so each instance reads its Vercel runtime environment variables correctly.

Copy link to headingQueues and scheduled tasks

Long-running Laravel queue workers and the in-process scheduler assume the container stays active. Use Vercel Cron to call an authenticated HTTP endpoint on a schedule and Queues for durable multi-step work.

Copy link to headingTroubleshooting

Copy link to headingRequests return 502

Cause: FrankenPHP failed to start, listens only on localhost, or listens on a port other than the configured PORT.

Fix: Keep :{$PORT:80} in Caddyfile so the server follows Vercel's port contract.

Copy link to headingLaravel reports that no application encryption key is set

Cause: APP_KEY is missing from the deployment environment.

Fix: Generate the key once and add it with vercel env add APP_KEY. Redeploy after changing environment variables.

Copy link to headingRequests fail while writing cache or session files

Cause: storage or bootstrap/cache is not writable, or the application uses a local driver across multiple instances.

Fix: Preserve the ownership commands in Dockerfile.vercel and configure an external session or cache store when data must be shared.

More Vercel Container Registry guides