Polaris

Deploy Laravel with Reverb workers using Kamal

A production-minded walkthrough for deploying one Laravel Docker image as web, queue, and Reverb roles with Kamal, including the proxy details that usually cause the first deploy to fail.

Laravel 13 · Kamal 2 · Reverb

Deploy Laravel with Kamal 2: Web, Queues, and Reverb from One Image

A Laravel application with queue workers and real-time broadcasting does not need three separate images. It needs one immutable image, three clearly defined runtime roles, and two public routes. This guide builds that deployment with Kamal, Laravel Reverb, and the Server Side Up PHP image.

1. Design the runtime before writing YAML

The clean deployment model is one image running in three containers. Each container receives the same application code, but Kamal gives each role a different command and deployment policy.

app.example.com
  └── kamal-proxy
      └── web container :8080

reverb.example.com
  └── kamal-proxy
      └── reverb container :8000

queue container
  └── no public route
Role Command Public traffic Responsibility
web Image default app.example.com HTTP requests, static assets, and Laravel routes
queue php artisan queue:work None Background jobs
reverb php artisan reverb:start reverb.example.com WebSocket connections and Reverb API requests

2. Prepare the application and the server

Before the first deployment, verify these prerequisites:

  • app.example.com and reverb.example.com resolve to the server.
  • Ports 80 and 443 are reachable.
  • Your registry credentials can push and pull the application image.
  • Laravel Reverb and the browser client are already installed and configured.
  • The project contains the migrations required by its session, cache, and queue drivers.

In production, restrict Reverb's allowed_origins in config/reverb.php to the hostnames that should be allowed to open WebSocket connections. Do not leave the value at * unless every origin is intentionally trusted.

3. Make Laravel trust the HTTPS proxy

Kamal terminates TLS at kamal-proxy and forwards the request to the web container over HTTP. When ssl: true is enabled, Kamal does not forward X-Forwarded-For and X-Forwarded-Proto unless forward_headers: true is set.

Both sides must be configured: Kamal must send the forwarded headers, and Laravel must trust them. Add the proxy configuration to bootstrap/app.php:

use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;

->withMiddleware(function (Middleware $middleware): void {
    $middleware->trustProxies(
        at: '*',
        headers: Request::HEADER_X_FORWARDED_FOR |
            Request::HEADER_X_FORWARDED_HOST |
            Request::HEADER_X_FORWARDED_PORT |
            Request::HEADER_X_FORWARDED_PROTO
    );
})

Trusting * is reasonable when the application container can only be reached through the deployment proxy. If the container is exposed directly, replace * with the proxy's IP address or network range. Without this configuration, helpers such as url() and route() may generate http:// URLs and trigger mixed-content errors.

4. Build one production image for all three roles

Use a multi-stage build: a supported Node.js LTS image compiles the frontend, while the final image contains only PHP, Nginx, the application, and the compiled assets. That keeps Node.js and node_modules out of the runtime image.

The VITE_REVERB_* values belong in the asset build stage. Vite replaces them while compiling the JavaScript bundle; setting them only on the running container is too late.

# syntax=docker/dockerfile:1

FROM node:24-alpine AS assets

WORKDIR /app

ARG VITE_REVERB_APP_KEY
ARG VITE_REVERB_HOST
ARG VITE_REVERB_PORT=443
ARG VITE_REVERB_SCHEME=https

ENV VITE_REVERB_APP_KEY=${VITE_REVERB_APP_KEY} \
    VITE_REVERB_HOST=${VITE_REVERB_HOST} \
    VITE_REVERB_PORT=${VITE_REVERB_PORT} \
    VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME}

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build


FROM serversideup/php:8.3-fpm-nginx AS production

USER root
RUN install-php-extensions exif

USER www-data
WORKDIR /var/www/html

COPY --chown=www-data:www-data composer.json composer.lock ./
RUN composer install \
    --no-dev \
    --no-interaction \
    --no-scripts \
    --prefer-dist

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

RUN composer dump-autoload \
    --no-dev \
    --optimize

USER root
RUN mkdir -p /var/www/html/storage/database \
    && touch /var/www/html/storage/database/database.sqlite \
    && chown -R www-data:www-data /var/www/html/storage

USER www-data

ENV SSL_MODE=off \
    AUTORUN_ENABLED=true \
    AUTORUN_LARAVEL_MIGRATION=false \
    PHP_OPCACHE_ENABLE=1 \
    HEALTHCHECK_PATH=/up

SSL_MODE=off is intentional: kamal-proxy, not Nginx inside the container, owns TLS. Automatic Laravel optimizations remain enabled, but automatic migrations are disabled. Otherwise, the web, queue, and Reverb containers could all try to migrate the same database during startup.

The sample uses npm. For pnpm or Yarn, replace the lockfile copy and install command with the equivalent frozen-lockfile workflow.

Keep secrets out of the build context

Add at least the following entries to .dockerignore:

.git
.env
.env.*
!.env.example
.kamal/secrets
node_modules
vendor
public/build

Values prefixed with VITE_ are public by design because they are bundled into browser code. The Reverb app key may be public; the Reverb app secret and Laravel APP_KEY must never be passed as Vite variables or ordinary Docker build arguments.

5. Configure Kamal by role

The primary web role uses the root proxy configuration. The queue role has no public route. The reverb role receives its own proxy configuration because it listens on a different container port and public hostname.

service: laravel_reverb
image: your-github-user/laravel-reverb

servers:
  web:
    hosts:
      - 203.0.113.10

  queue:
    hosts:
      - 203.0.113.10
    cmd: php /var/www/html/artisan queue:work --sleep=3 --tries=3 --timeout=90
    stop_timeout: 120
    options:
      stop-signal: SIGTERM
      health-cmd: healthcheck-queue
      health-start-period: 10s
      health-interval: 5s
      health-retries: 5

  reverb:
    hosts:
      - 203.0.113.10
    cmd: php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8000
    stop_timeout: 30
    options:
      stop-signal: SIGTERM
      health-cmd: healthcheck-reverb
      health-start-period: 10s
      health-interval: 5s
      health-retries: 5
    proxy:
      host: reverb.example.com
      ssl: true
      app_port: 8000
      forward_headers: true
      healthcheck:
        path: /up

proxy:
  host: app.example.com
  ssl: true
  app_port: 8080
  forward_headers: true
  healthcheck:
    path: /up

registry:
  server: ghcr.io
  username: your-github-user
  password:
    - KAMAL_REGISTRY_PASSWORD

builder:
  arch: amd64
  args:
    VITE_REVERB_APP_KEY: "app-key"
    VITE_REVERB_HOST: "reverb.example.com"
    VITE_REVERB_PORT: "443"
    VITE_REVERB_SCHEME: "https"

env:
  clear:
    APP_NAME: "Laravel"
    APP_ENV: "production"
    APP_DEBUG: "false"
    APP_URL: "https://app.example.com"

    LOG_CHANNEL: "stderr"
    SESSION_DRIVER: "database"
    CACHE_STORE: "database"
    QUEUE_CONNECTION: "database"

    DB_CONNECTION: "sqlite"
    DB_DATABASE: "/var/www/html/storage/database/database.sqlite"

    BROADCAST_CONNECTION: "reverb"
    REVERB_APP_ID: "app-id"
    REVERB_APP_KEY: "app-key"
    REVERB_HOST: "reverb.example.com"
    REVERB_PORT: "443"
    REVERB_SCHEME: "https"

    REVERB_SERVER_HOST: "0.0.0.0"
    REVERB_SERVER_PORT: "8000"

  secret:
    - APP_KEY
    - REVERB_APP_SECRET

volumes:
  - laravel_storage:/var/www/html/storage

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

aliases:
  artisan: app exec -p -i --reuse -r web "php artisan"
  migrate: app exec -p --reuse -r web "php artisan migrate --force"
  migrate-status: app exec -p --reuse -r web "php artisan migrate:status"
  tinker: app exec -p -i --reuse -r web "php artisan tinker"
  web-logs: app logs -f -r web
  queue-logs: app logs -f -r queue
  reverb-logs: app logs -f -r reverb

Store runtime secrets separately

Keep secrets in .kamal/secrets, make sure the file is ignored by Git, and reference only their names under env.secret:

KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD
APP_KEY=$APP_KEY
REVERB_APP_SECRET=$REVERB_APP_SECRET

Understand the three Reverb configuration layers

Variables Consumed by Meaning
REVERB_SERVER_HOST
REVERB_SERVER_PORT
The Reverb daemon Where Reverb listens inside the container: 0.0.0.0:8000
REVERB_HOST
REVERB_PORT
REVERB_SCHEME
Laravel's server-side broadcaster The public endpoint Laravel uses when publishing events
VITE_REVERB_* The browser bundle The public endpoint compiled into the JavaScript during npm run build

Keep REVERB_APP_KEY and VITE_REVERB_APP_KEY identical. The app key identifies the Reverb application; it is not the secret used to sign requests.

6. Deploy once, then run migrations once

On a fresh host, kamal setup installs Docker when permitted, boots accessories, and performs the first deployment. Future releases use kamal deploy.

# Run once when adding Kamal to the project:
kamal init

# Edit config/deploy.yml and .kamal/secrets, then inspect the result:
kamal config

# First deployment to a fresh host:
kamal setup

# Create or update the database schema on one primary web container:
kamal migrate

Commit before deploying. Unless you override the build context, Kamal builds from a clean Git clone and therefore excludes uncommitted changes.

git status
git add .
git commit -m "Prepare Laravel deployment"

kamal deploy

# Run only when this release contains migrations:
kamal migrate

Disabling startup migrations is deliberate. All three roles use the same image, so letting every container migrate at boot creates avoidable races. The migrate alias runs the command once on the primary web host.

For zero-downtime schema changes, deploy code that can run against both the old and new schema, apply the migration, and remove obsolete columns or tables in a later release. Do not combine a destructive migration with code that requires the new schema immediately.

7. Verify the deployment before debugging the application

Check routing, health endpoints, container state, and migrations in that order:

dig +short app.example.com
dig +short reverb.example.com

curl --fail --silent --show-error https://app.example.com/up
curl --fail --silent --show-error https://reverb.example.com/up

kamal details
kamal proxy details
kamal migrate-status

kamal web-logs
kamal queue-logs
kamal reverb-logs

Then open the browser's developer tools, select the Network panel, filter for WebSocket requests, and trigger an event. The connection should use wss://reverb.example.com/app/.... Reverb also serves HTTP API requests below /apps, so the proxy must forward both paths to the same Reverb container.

Troubleshooting guide

Symptom Likely cause What to check
Laravel generates http:// URLs Forwarded headers are missing or untrusted forward_headers: true and trustProxies
Reverb deploy times out The proxy cannot reach its readiness endpoint app_port: 8000, the Reverb command, and /up
The WebSocket request returns 404 The browser is using the wrong host or proxy route VITE_REVERB_HOST, DNS, and the Reverb role's proxy block
The WebSocket request returns 401 or 403 Credentials or allowed origins do not match App ID, app key, app secret, and allowed_origins
Changing VITE_REVERB_* has no effect The JavaScript bundle was not rebuilt builder.args and a new image build
The queue role is unhealthy The worker exited or the command does not match the image kamal queue-logs and healthcheck-queue
SQLite reports locks under load Web, sessions, cache, and queues are contending for one file Move production workloads to PostgreSQL or MySQL

The operating model is straightforward: Kamal builds images, starts role-specific containers, checks readiness, and switches proxy routes. Laravel remains responsible for application health, migrations, queues, and broadcasting. Keeping that boundary explicit makes failures easier to isolate.

References

Give your Kamal setup a beautiful native UI

Polaris brings deploys, logs, containers, proxy routes, request traffic, and rollbacks into one focused macOS app. Keep using Kamal, just with a calmer interface on top.