Deploy Laravel to a VPS with Kamal 2: A Complete Guide

Deploy Laravel with Kamal 2 to any VPS: build the Dockerfile, wire secrets and a MySQL accessory, enable Let's Encrypt SSL, and ship zero-downtime redeploys.

Steven Richardson
Steven Richardson
· 16 min read

Somewhere between "click Deploy in a control panel" and "hire a platform team to run Kubernetes" there is a gap, and most Laravel apps live in it. You want a repeatable, ownable deploy on cheap hardware you control, without YAML sprawl or a managed-platform bill. That is exactly what Kamal is for, and this guide shows you how to deploy Laravel with Kamal 2 from an empty VPS to a live, HTTPS, database-backed app. We will build a production Dockerfile, wire up secrets, attach a MySQL accessory and Redis, run queue and scheduler roles, enable Let's Encrypt SSL through Kamal's own proxy, and run migrations safely on every zero-downtime redeploy.

Everything here targets Kamal 2.12, Laravel 12, and PHP 8.4. I run this exact setup for side projects and client apps that don't justify a Forge or Vapor bill, and once it is wired up, shipping is a single kamal deploy.

Install Kamal and initialise the config#

Kamal ships as a Ruby gem, so install it on your local machine (or your CI runner) and confirm the version before touching a server. You do not need Ruby on the target VPS; Kamal only uses Ruby locally to orchestrate Docker over SSH.

gem install kamal
kamal version
# => 2.12.0

If you would rather not manage a Ruby toolchain, Kamal is also published as a Docker image you can wrap in a shell alias:

alias kamal='docker run -it --rm \
  -v "${PWD}:/workdir" \
  -v "${SSH_AUTH_SOCK}:/ssh-agent" \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -e "SSH_AUTH_SOCK=/ssh-agent" \
  ghcr.io/basecamp/kamal:latest'

From the root of your Laravel project, scaffold the config:

kamal init

That creates config/deploy.yml (the single file that describes your entire deployment), a .kamal/secrets file for sensitive values, and a .kamal/hooks/ directory with example lifecycle hooks. The whole rest of this guide is really just filling in config/deploy.yml and .kamal/secrets correctly.

Write a production Dockerfile for Laravel#

The only hard requirement Kamal places on your app is a Dockerfile that produces a runnable image. For Laravel the pragmatic choice is the ServerSideUp PHP images, which bundle PHP-FPM and Nginx tuned for Laravel, so you are not hand-rolling an FPM/Nginx config. Use a multi-stage build so Composer and Node never end up in the final image.

# syntax=docker/dockerfile:1

# --- Stage 1: Composer (production dependencies) --------------------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install \
      --no-dev \
      --no-interaction \
      --no-scripts \
      --prefer-dist \
      --optimize-autoloader

# --- Stage 2: Front-end assets --------------------------------------
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# --- Stage 3: Runtime (PHP-FPM + Nginx) -----------------------------
FROM serversideup/php:8.4-fpm-nginx AS runtime

# SSL is terminated by kamal-proxy, so keep Nginx on plain HTTP.
# Disable auto-migration: Kamal runs migrations once, on the primary host.
ENV SSL_MODE="off" \
    AUTORUN_ENABLED="true" \
    AUTORUN_LARAVEL_MIGRATION="false" \
    PHP_OPCACHE_ENABLE="1" \
    HEALTHCHECK_PATH="/up"

USER www-data
WORKDIR /var/www/html

COPY --chown=www-data:www-data . .
COPY --chown=www-data:www-data --from=vendor /app/vendor ./vendor
COPY --chown=www-data:www-data --from=assets /app/public/build ./public/build

A few Laravel-specific notes. SSL_MODE="off" matters: kamal-proxy handles HTTPS, so Nginx inside the container should speak plain HTTP or you will fight redirect loops. The ServerSideUp image runs a set of boot-time "Laravel automations" (config:cache, route:cache, view:cache, storage:link) when AUTORUN_ENABLED is true — which is what you want, because config is cached after Kamal injects the real environment. The one automation to switch off is the automatic migration; you do not want every web, worker, and cron container racing to migrate on boot. Add a .dockerignore (vendor/, node_modules/, .git, .env) so your build context stays small. If you would rather run an Octane runtime than PHP-FPM, my guide to deploying Laravel Octane with FrankenPHP swaps cleanly into this same Kamal flow.

Configure the registry and servers in deploy.yml#

Kamal builds your image locally, pushes it to a container registry, then pulls it onto each host over SSH, so it needs registry credentials and a list of servers. Open config/deploy.yml and start with the essentials. The top dotenv line lets you reuse values from your local .env without committing them.

<% require "dotenv"; Dotenv.load(".env") %>

service: acme
image: ghcr-user/acme

servers:
  web:
    - 203.0.113.10

registry:
  server: ghcr.io           # omit this line for Docker Hub
  username: ghcr-user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64

proxy:
  ssl: true
  host: acme.example.com
  app_port: 8080            # ServerSideUp's Nginx listens on 8080

env:
  clear:
    APP_NAME: "Acme"
    APP_ENV: production
    APP_DEBUG: "false"
    APP_URL: https://acme.example.com
    ASSET_URL: https://acme.example.com
    LOG_CHANNEL: stderr
  secret:
    - APP_KEY

asset_path: /var/www/html/public/build

aliases:
  console: app exec --interactive --reuse "bash"
  tinker: app exec --interactive --reuse "php artisan tinker"

service is the container name prefix; image is the repository path in your registry. The builder.arch: amd64 line matters more than it looks — I will come back to the cross-architecture trap it hides. The app_port: 8080 value points kamal-proxy at the port the ServerSideUp image actually serves on; the proxy itself owns ports 80 and 443. asset_path enables asset bridging: during a deploy both old and new containers are briefly live, and Kamal shares the compiled public/build directory between them so a user who loads the old HTML can still fetch the new hashed JS/CSS instead of a 404.

Prepare Laravel to run behind the proxy#

Two small application changes make Laravel behave correctly behind kamal-proxy: a health endpoint and trusted-proxy configuration. Laravel 11 and 12 already register a /up health route, which is exactly what the proxy polls to decide a new container is ready, so you usually get that for free. The change you must make is telling Laravel to trust the proxy.

// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        commands: __DIR__ . '/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->trustProxies(at: '*');
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();

Here is why this is non-negotiable. kamal-proxy terminates TLS and forwards plain HTTP to your container, setting X-Forwarded-Proto: https as it does. Without trustProxies, Laravel sees an HTTP request and generates http:// URLs, breaking asset links, redirects, and anything that calls route() or url() under HTTPS. Trusting the proxy tells Laravel to honour that forwarded header. One caveat: if you later put a CDN or load balancer in front of kamal-proxy, also set forward_headers: true in the proxy block so the original client headers survive the extra hop. Because the /up route drives readiness, it is worth understanding what it should actually assert — my write-up on Laravel health checks for Kubernetes probes digs into making health endpoints meaningful rather than a bare 200.

Store secrets in .kamal/secrets#

Sensitive values never belong in config/deploy.yml or the image. Kamal keeps them in .kamal/secrets, a git-ignored file that uses dotenv syntax and pulls real values from your shell environment or a password-manager CLI at deploy time. Anything listed under a secret: key in deploy.yml must resolve here.

# .kamal/secrets  (git-ignored!)
KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
APP_KEY=$APP_KEY
DB_PASSWORD=$DB_PASSWORD
MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD
REDIS_PASSWORD=$REDIS_PASSWORD

The $VALUE syntax reads the matching variable from the environment on your deploy machine, so you export them locally or, better, fetch them from a vault:

export APP_KEY=$(grep '^APP_KEY=' .env | cut -d '=' -f2-)
export KAMAL_REGISTRY_PASSWORD='ghp_your_registry_token'
# Or, with a password manager:
# kamal secrets fetch --adapter 1password --account your-team ...

Two rules save you real pain. First, confirm .kamal/secrets is in .gitignorekamal init adds it, but check, because committing it once means rotating everything. Second, on Docker Hub and GHCR the KAMAL_REGISTRY_PASSWORD must be an access token, not your account password; a password will fail non-interactive logins on the hosts. Secrets are written to an env file on the host with locked-down permissions and injected into docker run, never baked into an image layer where docker history could leak them.

Provision the server and run the first deploy#

Kamal installs Docker for you and does not need a runtime pre-installed on the box, but it deliberately does not harden the server — that part is yours. Provision a fresh Ubuntu 24.04 VPS, add your SSH key for root (or a sudo user), point DNS at it, and lock it down before the first deploy.

# On the server (or via your provisioning tool):
ufw allow 22 && ufw allow 80 && ufw allow 443 && ufw enable
apt-get update && apt-get install -y fail2ban

Point an A record at the server early — Let's Encrypt validation needs the hostname resolving to this box, and DNS can take time to propagate:

acme.example.com.  300  IN  A  203.0.113.10

Now run the one-time setup, which installs Docker on the host, boots kamal-proxy, logs in to your registry, builds and pushes the image, and starts the first container:

kamal setup

Watch the output: you will see the local build, the push to your registry, the pull on the host, a health check against /up, and the traffic switch. When it finishes, your app is live over HTTP (SSL comes in a moment). Every deploy after this is just kamal deploy, or kamal redeploy when you have not added new hosts and want to skip the Docker-install check for speed.

Add a MySQL accessory with persistent storage#

A real app needs a database, and Kamal models supporting services as accessories — long-lived containers it boots alongside your app on the same Docker network. Because they share that network, your app reaches the database by the generated hostname service-accessory (here, acme-mysql) with no published ports. Add a MySQL 8.4 accessory with a persistent directory so your data survives redeploys.

accessories:
  mysql:
    image: mysql:8.4
    host: 203.0.113.10
    env:
      clear:
        MYSQL_DATABASE: acme
        MYSQL_USER: acme
      secret:
        - MYSQL_ROOT_PASSWORD
        - MYSQL_PASSWORD
    directories:
      - data:/var/lib/mysql

The directories mapping mounts a host directory into /var/lib/mysql, which is what makes the storage persistent — drop it and your database evaporates on the next boot. Now point Laravel at it by extending the env block in deploy.yml:

env:
  clear:
    DB_CONNECTION: mysql
    DB_HOST: acme-mysql
    DB_PORT: "3306"
    DB_DATABASE: acme
    DB_USERNAME: acme
    # ... plus the APP_* values from earlier
  secret:
    - APP_KEY
    - DB_PASSWORD

Add a matching MYSQL_PASSWORD=$DB_PASSWORD line to .kamal/secrets so the database user and the app share one credential. Boot the accessory (it also comes up during kamal setup):

kamal accessory boot mysql

Keeping the database on the same box is cheap and fast, but it makes backups entirely your responsibility. Wire that up early — automating Laravel database backups to S3 covers a scheduled spatie/laravel-backup job that dumps MySQL and ships it off-server, which is the difference between a bad afternoon and a catastrophe.

Add Redis and run queue and scheduler roles#

Most production Laravel apps need three process types — web, a queue worker, and the scheduler — plus a cache/queue backend. Add Redis (well, Valkey, the drop-in open-source fork) as a second accessory, then declare roles under servers. Each role is its own container from the same image; only the web role gets the proxy.

accessories:
  redis:
    image: valkey/valkey:8
    host: 203.0.113.10
    directories:
      - data:/data

servers:
  web:
    - 203.0.113.10
  worker:
    hosts:
      - 203.0.113.10
    cmd: php artisan queue:work --max-jobs=1000 --max-time=3600 --tries=3
  cron:
    hosts:
      - 203.0.113.10
    cmd: php artisan schedule:work

Then move Laravel's cache, session, and queue onto Redis in the env.clear block (REDIS_HOST: acme-redis, CACHE_STORE: redis, QUEUE_CONNECTION: redis, SESSION_DRIVER: redis) and add REDIS_PASSWORD to your secrets. The --max-jobs and --max-time flags on the worker are deliberate: long-lived PHP workers leak memory, and recycling them on a bound is the fix I detail in stopping Laravel queue workers from leaking memory. The cron role runs schedule:work as a foreground process, which is the container-native way to drive the scheduler instead of system cron — there is more on that pattern in running the Laravel scheduler in a Docker container.

Kamal deliberately runs one container per role per host rather than scaling replicas, so you scale by giving the worker container more processes or adding hosts. When a single box stops keeping up, scaling Laravel queues in production and monitoring them with Horizon cover the multi-server story. If your app pushes real-time updates, a Reverb websocket server slots in as yet another role — see scaling Laravel Reverb in production.

Enable SSL with Kamal's built-in proxy#

kamal-proxy can issue and renew a Let's Encrypt certificate automatically, so HTTPS is a single line rather than a Certbot cron job. You already set it in the proxy block; the full set of options lives in the Kamal proxy documentation, but here is what each one is doing.

proxy:
  ssl: true               # automatic Let's Encrypt certificate
  host: acme.example.com  # must resolve to THIS server
  app_port: 8080
  healthcheck:
    path: /up             # the default; shown here for clarity

Automatic SSL has three requirements, and missing any one is the usual reason a cert fails: you must deploy to a single host, the host value must point at that server in DNS, and port 443 must be open for the ACME challenge. When those hold, kamal-proxy requests the certificate on first deploy and renews it in the background. During deploys it polls /up once a second (five-second per-request timeout) until the container is healthy, then swaps traffic — that is the "gapless" part. By default it also redirects HTTP to HTTPS; set ssl_redirect: false only if you have a specific reason to pass plain HTTP through. If you deploy across multiple servers, automatic Let's Encrypt no longer applies and you supply a certificate via ssl.certificate_pem/private_key_pem mapped to secrets, terminating TLS at a load balancer in front.

Run migrations on every zero-downtime deploy#

Migrations are the one deploy step that can cause an outage if you run them wrong, so run them once, on one host, against the new image, before traffic switches. Kamal's pre-deploy hook is built for this. Create .kamal/hooks/pre-deploy, make it executable, and have it exec into a single container:

#!/bin/bash
set -e

echo "Running database migrations on the primary host..."
kamal app exec --primary --version="$KAMAL_VERSION" \
  "php artisan migrate --force"
chmod +x .kamal/hooks/pre-deploy

The --primary flag restricts execution to the primary host so migrations run exactly once even across a multi-server rollout, and --version="$KAMAL_VERSION" (an environment variable Kamal sets inside hooks) targets the freshly pulled image rather than the currently live one. --force is required to run migrations non-interactively in production. Because the hook runs before the traffic switch, the old container keeps serving requests against the old schema while migrations apply — which only stays truly zero-downtime if each migration is backward-compatible with the running code. That discipline is the whole game, and I cover it in depth in zero-downtime Laravel migrations with the expand/contract pattern: add columns before you use them, backfill in batches, and drop old columns only after the new code is live.

Roll back a bad deploy and avoid the common traps#

When a deploy goes wrong, you want the previous version back in seconds, not a frantic rebuild. Kamal retains the last five images by default, so rolling back is a single command against a prior version tag:

kamal app versions          # list deployed versions (git SHAs)
kamal rollback <VERSION>     # boot the previous image and switch traffic

Rollback re-runs the same health-check-then-switch dance in reverse, so traffic only moves once the old container is confirmed healthy. A handful of traps account for most first-time failures, and it is worth internalising them:

  • The cross-architecture build trap. If you develop on an Apple Silicon Mac (arm64) and deploy to an amd64 VPS, builder.arch: amd64 forces a slow, emulated QEMU build that is easy to get subtly wrong. Build on a matching architecture instead — either in CI on an amd64 runner, or with a remote builder Kamal offloads to natively:

    builder:
      arch: amd64
      remote: ssh://docker@203.0.113.20   # an amd64 box running Docker
    
  • Secrets baked into the image. Keep them in .kamal/secrets; never ENV APP_KEY=... in the Dockerfile.

  • Forgetting trustProxies. The symptom is mixed-content warnings or redirect loops, because Laravel thinks it is on HTTP.

  • A registry password instead of a token. Non-interactive logins on the hosts will fail.

  • DNS not resolving before kamal setup. The Let's Encrypt challenge fails and the cert never issues.

  • Auto-migrating on every container. Disable the image's boot-time migration and let the pre-deploy hook own it.

Wrapping up#

You now have a stock Laravel 12 app running on a VPS you control: a multi-stage Docker image, a single config/deploy.yml describing web, worker, and cron roles, a MySQL accessory with persistent storage, Redis for cache and queues, automatic Let's Encrypt SSL through kamal-proxy, and migrations that run once per deploy without downtime. Day-to-day, shipping is one kamal deploy, and recovering is one kamal rollback.

From here, three directions are worth your time. If you decide you would rather trade control for convenience, weigh the options in Laravel Vapor vs Forge in 2026. If you want more requests per core on the same box, running Laravel Octane with FrankenPHP drops into this Dockerfile with a runtime swap. And if you genuinely outgrow a single server and need orchestration, taking a Laravel Dockerfile to a Kubernetes pod is the honest next step up — but most apps never need it, and Kamal is why.

FAQ#

What is Kamal and how does it work?

Kamal is a deployment tool from 37signals (the makers of Basecamp and HEY) that runs your Dockerised app on any server you can SSH into. You describe the deployment in a single config/deploy.yml, and Kamal builds your image, pushes it to a registry, pulls it onto your hosts over SSH, boots a new container, health-checks it, and switches traffic to it. It is essentially a well-crafted set of Docker-over-SSH scripts, plus a lightweight proxy for gapless cutover — no agent runs permanently on the server beyond Docker and the proxy.

Do I need Kubernetes to deploy Laravel, or is Kamal enough?

For the vast majority of Laravel apps, Kamal is enough and Kubernetes is overkill. Kubernetes earns its complexity when you need autoscaling across a fleet, self-healing scheduling, and multi-team platform tooling. Kamal targets the far more common case: one or a few servers you own, deployed imperatively with zero-downtime cutover. You can always graduate later — the same Dockerfile that Kamal builds is the one you would ship to a pod, as covered in taking a Laravel Dockerfile to a Kubernetes pod.

How does Kamal handle zero-downtime deployments?

Kamal uses its built-in kamal-proxy to achieve gapless deploys. When you run kamal deploy, it boots the new container alongside the old one, polls your /up health endpoint once a second until the app responds, and only then tells the proxy to route traffic to the new container before stopping the old one. Combined with asset bridging (sharing the compiled public/build directory across both containers), users mid-session never hit a 404 or a dropped request during the switch.

How do I run database migrations with Kamal?

Run them from a pre-deploy hook so they execute once, on the primary host, against the newly built image before traffic switches. The hook calls kamal app exec --primary --version="$KAMAL_VERSION" "php artisan migrate --force". This ordering keeps deploys zero-downtime as long as your migrations are backward-compatible with the currently running code — add and backfill in one deploy, remove in a later one. The expand/contract migration pattern is the safe way to structure those changes.

How do I add MySQL or Redis to a Kamal deployment?

Declare them as accessories in config/deploy.yml. Each accessory is a container Kamal boots on the same Docker network as your app, so you connect to it by the generated hostname service-accessory (for example acme-mysql or acme-redis) with no published ports. Give database and Redis accessories a directories mapping to a host path so their data persists across redeploys, and pass credentials through the env.secret list rather than in plain text. Boot or update them with kamal accessory boot mysql (or redis).

How does Kamal set up SSL certificates?

kamal-proxy requests and renews a Let's Encrypt certificate automatically when you set ssl: true and a host in the proxy block. Three conditions must hold: you deploy to a single server, the host value resolves to that server in DNS, and port 443 is open so the ACME challenge can complete. The proxy then terminates TLS for you and redirects HTTP to HTTPS by default. For multi-server setups where automatic issuance does not apply, you supply a custom certificate via ssl.certificate_pem and private_key_pem mapped to secrets.

Can I deploy Laravel with Kamal to multiple servers?

Yes. List multiple hosts under a role and Kamal deploys to all of them, running its health-check-then-switch cutover on each. The main change is SSL: automatic Let's Encrypt is single-server only, so multi-server deployments terminate TLS at a load balancer in front of the hosts (or use a custom certificate), and you set forward_headers: true so client headers survive the extra hop. Because Kamal runs one container per role per host, you scale horizontally by adding hosts and letting the proxy and your queue workers spread the load, as covered in scaling Laravel queues in production.

Kamal vs Laravel Forge vs Envoyer — which should I use?

Forge provisions and manages servers for you (Nginx, PHP, SSL, queues) and Envoyer adds zero-downtime deploys on top, both as paid hosted services that run your app the traditional way with system PHP-FPM. Kamal is free, open-source, and container-based: you own the box and ship a Docker image, getting zero-downtime cutover and SSL built in. Choose Kamal when you want portability, containerisation, and no per-server subscription; choose Forge/Envoyer when you would rather pay to avoid touching Docker or server config at all. My full breakdown in Laravel Vapor vs Forge in 2026 maps where each option fits.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.