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.comandreverb.example.comresolve to the server.- Ports
80and443are 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_HOSTREVERB_SERVER_PORT |
The Reverb daemon | Where Reverb listens inside the container: 0.0.0.0:8000 |
REVERB_HOSTREVERB_PORTREVERB_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
- Kamal: roles, proxy configuration, builders, environment variables, and the deployment flow
- Laravel: trusted proxies and Reverb
- Server Side Up: Reverb containers, queue workers, Laravel health checks, and the environment variable reference
- Vite: environment variables and build modes
- Node.js: release and support schedule
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.