Skip to content
Docs

Deploy Symfony on Vercel with Docker

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

Anshuman BhardwajContent Engineer

Symfony 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 Symfony application, warms its production cache during the image build, and keeps runtime configuration outside the image.

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

  • src/Controller/StatusController.php returns JSON for / and /health.
  • Caddyfile serves static files only from public/ and routes application requests to public/index.php.
  • Dockerfile.vercel installs production dependencies, clears the production cache, and copies the complete Symfony application into FrankenPHP.
  • vercel.json declares the container service and routes requests to it.

Copy link to headingSteps

Copy link to heading1. Create the Symfony application

Create a Symfony 7.4 LTS project:

Terminal
composer create-project symfony/skeleton:"7.4.*" vercel-docker-symfony
cd vercel-docker-symfony

Create src/Controller/StatusController.php:

src/Controller/StatusController.php
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class StatusController
{
#[Route('/', name: 'app_home', methods: ['GET'])]
public function home(): JsonResponse
{
return new JsonResponse([
'framework' => 'Symfony',
'message' => 'Hello from Symfony and FrankenPHP on Vercel',
]);
}
#[Route('/health', name: 'app_health', methods: ['GET'])]
public function health(): JsonResponse
{
return new JsonResponse(['status' => 'ok']);
}
}

The route attributes are discovered through the controller resource in config/routes.yaml.

Copy link to heading2. Configure FrankenPHP

Create Caddyfile in the project root:

Caddyfile
:{$PORT:80} {
root * /app/public
encode zstd gzip
file_server
php_server {
try_files {path} index.php
}
@phpFile path *.php*
error @phpFile "Not found" 404
}

The server follows Vercel's PORT, serves static files from public, and returns 404 for other PHP paths instead of executing them.

Copy link to heading3. Add Dockerfile.vercel

Create Dockerfile.vercel at 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
ENV APP_ENV=prod \
APP_DEBUG=0
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock symfony.lock ./
RUN composer install \
--no-dev \
--no-interaction \
--no-progress \
--no-scripts \
--optimize-autoloader \
--prefer-dist
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative \
&& php bin/console cache:clear
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 /app/var
ENV PORT=80 \
APP_ENV=prod \
APP_DEBUG=0
USER www-data
CMD ["frankenphp", "run", "--config", "/etc/frankenphp/Caddyfile"]

The dependency layer installs packages with Composer scripts disabled. After copying the source, the build regenerates an authoritative autoloader and explicitly clears and warms Symfony’s production cache.

Copy link to heading4. Exclude local files from the image

Create a .dockerignore file at the project root to exclude files from the Docker image:

.dockerignore
.git
.env.local
.env.*.local
node_modules
tests
var
vendor

Symfony's committed .env contains non-secret defaults. Keep production secrets in Vercel environment variables or uncommitted .env.local files, which this configuration excludes from the image.

Copy link to heading5. Add vercel.json

Create a vercel.json file at the project root to configure the Vercel 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 Symfony service.

Copy link to heading6. Run locally

Start the application through Vercel's local container runtime. The -L flag uses the local project configuration without linking to a Vercel project:

Terminal
vercel dev -L --listen 8080

Test both routes:

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

Copy link to heading7. Deploy to Vercel

After both local requests succeed, deploy the project to production:

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 headingEnvironment variables

Add APP_SECRET, DATABASE_URL, and other application values in the Vercel project settings or with vercel env add NAME. Real environment variables override values from Symfony's configuration .env file.

When relying exclusively on runtime environment variables, you can generate an optimized empty environment map during the build with composer dump-env prod --empty. Do not compile production secret values into the image.

Copy link to headingDatabase and migrations

Install the PDO extension required by your database, such as pdo_pgsql or pdo_mysql, with install-php-extensions. A Vercel deployment can run multiple container instances, and each concurrent PHP request (or each FrankenPHP worker when worker mode is enabled) can open a connection. Use an external pooler or proxy when needed, and set its limits to match expected concurrency.

Use a serverless-compatible database, external connection pooler, or database proxy. Open connections lazily, keep pool sizes conservative, use short idle timeouts, and deploy the Function in a region close to the database. Run Doctrine migrations as a separate release or CI step rather than during container startup.

Copy link to headingCache, files, and logs

The image contains Symfony’s warmed production cache, which reduces startup work for new instances. Although /app/var is writable at runtime, its contents are ephemeral, local to one container instance, and discarded when that instance is replaced or scaled down. Do not use the local file system for uploads, sessions, queues, or other durable state. Store durable data in an external database, cache, or object store such as Vercel Blob. Write production logs to stderr so Vercel can collect them.

Copy link to headingAssets

If the project uses AssetMapper, run php bin/console asset-map:compile during the image build. If it uses Webpack Encore, build assets in a Node.js stage and copy the generated files into public/build.

Copy link to headingMessenger and scheduled commands

Long-running Symfony Messenger workers and in-process schedulers assume the container stays active. Do not run messenger:consume or an in-process scheduler as an always-on process because the Function may shut down midway. Use Vercel Cron for authenticated scheduled endpoints and Queues for durable multi-step work.

Copy link to headingOptionally enable FrankenPHP worker mode

Symfony 7.4 supports FrankenPHP worker mode natively. To enable it, add worker /app/public/index.php inside the php_server block. This boots one Symfony kernel per FrankenPHP worker and reuses it while the container instance remains active. Treat all in-memory state as ephemeral and local to that worker. Audit stateful services and implement ResetInterface where necessary. Symfony’s runtime recycles workers after 500 requests by default; configure this with FRANKENPHP_LOOP_MAX.

Copy link to headingTroubleshooting

Copy link to headingRequests return 502

Cause: FrankenPHP is listening on a fixed port or only on localhost.

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

Copy link to headingRoutes return 404

Cause: The document root or front-controller fallback is incorrect.

Fix: Keep root * /app/public and try_files {path} index.php, then confirm the route with php bin/console debug:router.

Copy link to headingThe application uses development configuration

Cause: APP_ENV or APP_DEBUG overrides the production defaults.

Fix: Set APP_ENV=prod and APP_DEBUG=0, clear the cache, and redeploy.

Related documentation

More Vercel Services guides