How I deploy Laravel apps using Coolify and Docker
Laravel ecosystem is rich especially when it comes to deploying the application to production. You can choose using first-party solutions such as Forge or Cloud. Unfortunately those can get very expensive or inflexible when it comes to deploying and running multiple projects and databases.
Updated November 2026: the Dockerfile and docker-compose.yml below reflect the setup I currently run in production, taken from my Laravel skeleton.
Why I Chose Coolify
Coolify caught my attention due to its simplicity and powerful capabilities. As a self-hostable platform-as-a-service (PaaS), it abstracts many complexities involved in deploying applications while allowing full control over the server environment. I self-host all of my projects with it, and a single server can run several apps together with their Postgres and Redis instances. Coolify integrates with Git, so a push is all it takes to deploy.
Why I Use Docker
Docker plays a crucial role in my deployment strategy. A production Laravel app is not one process. It is the web server, queue workers, a scheduler, the Inertia SSR server and the Nightwatch agent. Docker lets me describe all of them in one compose file and build them from one Dockerfile, so every environment runs exactly the same thing.
What This Setup Gives Me
- Consistent environments: every deployment is built from the same Dockerfile, which removes a whole class of "works on my machine" bugs.
- Small, purpose-built images: the web, worker and SSR containers each get only what they need.
- Scaling by environment variable: replicas for the app, the queue workers and SSR are set through
APP_REPLICAS,QUEUE_WORKERSandSSR_REPLICAS. - Automated deployments: Coolify rebuilds and redeploys on every push.
The Dockerfile
The Dockerfile is a multi-stage build on top of serversideup/php v5 images with PHP 8.5. I pin the exact image version (v5.0.5 at the moment) so an upstream release can never change production without a commit.
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS bun-source
FROM serversideup/php:8.5-cli-v5.0.5 AS worker-base
USER root
RUN install-php-extensions intl bcmath gd gmp
USER www-data
FROM serversideup/php:8.5-frankenphp-v5.0.5 AS web-base
USER root
RUN install-php-extensions intl bcmath gd gmp
USER www-data
# vendor: composer deps cached on composer.lock, then full source + scripts
FROM worker-base AS vendor
COPY --chown=www-data:www-data composer.json composer.lock /var/www/html/
RUN --mount=type=cache,target=/composer/cache,uid=33,gid=33 \
composer install --no-dev --no-interaction --no-scripts --no-autoloader
COPY --chown=www-data:www-data . /var/www/html
RUN --mount=type=cache,target=/composer/cache,uid=33,gid=33 \
composer install --no-dev --no-interaction --optimize-autoloader
# assets: bun build once for web+ssr; throwaway APP_KEY never leaves this RUN
FROM vendor AS assets
ARG VITE_APP_NAME
ARG VITE_REVERB_APP_KEY
ARG VITE_REVERB_HOST
ARG VITE_REVERB_PORT
ARG VITE_REVERB_SCHEME
ENV BUN_INSTALL_CACHE_DIR=/tmp/bun-cache
COPY --from=bun-source /usr/local/bin/bun /usr/local/bin/bun
RUN --mount=type=cache,target=/tmp/bun-cache,uid=33,gid=33 \
bun install --frozen-lockfile
RUN printf "APP_KEY=base64:%s\nAPP_ENV=production\nCACHE_STORE=array\nSESSION_DRIVER=array\nQUEUE_CONNECTION=sync\nDB_CONNECTION=sqlite\nLOG_CHANNEL=stderr\n" \
"$(php -r 'echo base64_encode(random_bytes(32));')" > .env \
&& touch database/database.sqlite \
&& bun run build:ssr \
&& rm -f .env database/database.sqlite
# worker: PHP + vendor only (no bun, no node_modules, no built assets)
FROM worker-base AS worker
ENV PHP_OPCACHE_ENABLE=1
COPY --from=vendor --chown=www-data:www-data /var/www/html /var/www/html
# web: PHP + built assets (no node_modules, no bun)
FROM web-base AS web
ENV PHP_OPCACHE_ENABLE=1
COPY --from=vendor --chown=www-data:www-data /var/www/html /var/www/html
COPY --from=assets --chown=www-data:www-data /var/www/html/public/build /var/www/html/public/build
# ssr: PHP + ssr bundle + bun + node_modules (Vite SSR externalizes deps at runtime)
FROM web-base AS ssr
ENV PHP_OPCACHE_ENABLE=1
COPY --from=bun-source /usr/local/bin/bun /usr/local/bin/bun
COPY --from=vendor --chown=www-data:www-data /var/www/html /var/www/html
COPY --from=assets --chown=www-data:www-data /var/www/html/bootstrap/ssr /var/www/html/bootstrap/ssr
COPY --from=assets --chown=www-data:www-data /var/www/html/node_modules /var/www/html/node_modules
A few things worth pointing out:
- Three final targets.
webruns Octane on FrankenPHP and ships only the built assets.workeris a plain PHP CLI image for queues and the scheduler, without Bun or any frontend code.ssris the only image that carries Bun andnode_modules, because Vite's SSR bundle loads its dependencies at runtime. - Layer caching. Composer dependencies are installed from
composer.jsonandcomposer.lockbefore the source is copied, and both Composer and Bun use BuildKit cache mounts. A code change does not reinstall the vendor folder. - Assets are built once. The client bundle and the SSR bundle come from the same
build:ssrrun, and both images copy from that stage. - No real secrets at build time. Building the frontend boots Laravel (Wayfinder and the TypeScript types are generated from the PHP code), so it needs an
APP_KEY. The build writes a throwaway key and an in-memory configuration, then deletes it in the sameRUN, so it never ends up in an image layer. The only build arguments are the publicVITE_values that get compiled into the JavaScript.
The docker-compose.yml
The full file is in the skeleton repository. Here are the shared anchors and the app service, which is where most of the interesting configuration lives:
x-web-image: &web-image
build:
context: .
dockerfile: Dockerfile
target: web
args:
- VITE_APP_NAME=${VITE_APP_NAME}
- 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}
image: laravel-skeleton-web
x-app-volumes: &app-volumes
volumes:
- 'app-storage:/var/www/html/storage'
tmpfs:
- /tmp:noexec,nosuid,nodev,mode=1777
x-security-opts: &security-opts
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
x-logging: &logging
logging:
driver: 'json-file'
options:
max-size: '10m'
max-file: '3'
x-app-env-file: &app-env-file
env_file:
- .env
x-docker-environment: &docker-environment
NIGHTWATCH_INGEST_URI: 'nightwatch-agent:2409'
AUTORUN_ENABLED: false
PHP_MEMORY_LIMIT: ${PHP_MEMORY_LIMIT:-512M}
x-restart-policy: &restart-policy
restart: unless-stopped
init: true
networks:
# Internal traffic (app <-> ssr <-> nightwatch-agent) plus NAT egress.
app-network:
driver: bridge
# Shared Coolify network: Traefik ingress and Coolify-managed Postgres/Redis.
coolify:
external: true
services:
app:
<<: [*web-image, *app-volumes, *security-opts, *logging, *app-env-file, *restart-policy]
cap_add:
- NET_BIND_SERVICE
environment:
<<: *docker-environment
AUTORUN_ENABLED: true
AUTORUN_LARAVEL_MIGRATION_ISOLATION: true
INERTIA_SSR_ENABLED: true
INERTIA_SSR_URL: "http://ssr:13714"
command:
[
'php', 'artisan', 'octane:start',
'--server=frankenphp',
'--port=8080',
'--max-requests=500',
'--workers=4',
]
deploy:
replicas: ${APP_REPLICAS:-1}
resources:
limits:
memory: 2560M
reservations:
memory: 256M
labels:
- 'traefik.enable=true'
- 'traefik.docker.network=coolify'
- 'traefik.http.routers.app.rule=Host(`${APP_DOMAIN}`)'
- 'traefik.http.routers.app.entrypoints=https'
- 'traefik.http.routers.app.tls=true'
- 'traefik.http.routers.app.tls.certresolver=letsencrypt'
- 'traefik.http.routers.app-http.rule=Host(`${APP_DOMAIN}`)'
- 'traefik.http.routers.app-http.entrypoints=http'
- 'traefik.http.routers.app-http.middlewares=app-redirect-https'
- 'traefik.http.middlewares.app-redirect-https.redirectscheme.scheme=https'
- 'traefik.http.middlewares.app-redirect-https.redirectscheme.permanent=true'
- 'traefik.http.services.app.loadbalancer.server.port=8080'
- 'traefik.http.services.app.loadbalancer.sticky.cookie=true'
- 'traefik.http.services.app.loadbalancer.sticky.cookie.name=app_affinity'
- 'traefik.http.services.app.loadbalancer.sticky.cookie.httponly=true'
- 'traefik.http.services.app.loadbalancer.sticky.cookie.secure=true'
- 'traefik.http.services.app.loadbalancer.sticky.cookie.samesite=lax'
- 'traefik.http.services.app.loadbalancer.healthcheck.path=/up'
- 'traefik.http.services.app.loadbalancer.healthcheck.interval=10s'
- 'traefik.http.routers.app.middlewares=app-compress,app-headers'
- 'traefik.http.middlewares.app-compress.compress=true'
- 'traefik.http.middlewares.app-headers.headers.contenttypenosniff=true'
- 'traefik.http.middlewares.app-headers.headers.framedeny=true'
- 'traefik.http.middlewares.app-headers.headers.stsincludesubdomains=true'
- 'traefik.http.middlewares.app-headers.headers.stsseconds=31536000'
- 'traefik.http.middlewares.app-headers.headers.stspreload=true'
- 'traefik.http.middlewares.app-headers.headers.referrerpolicy=strict-origin-when-cross-origin'
- 'traefik.http.middlewares.app-headers.headers.permissionspolicy=camera=(), microphone=(), geolocation=()'
healthcheck:
test: ['CMD', 'healthcheck-octane']
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
stop_grace_period: 30s
networks:
- app-network
- coolify
depends_on:
ssr:
condition: service_healthy
restart: true
The other services follow the same pattern:
- worker runs
queue:workwith--max-time=3600and--memory=400on theworkerimage. The number of replicas comes fromQUEUE_WORKERS, and serversideup'shealthcheck-queuekeeps an eye on it. - scheduler runs
schedule:workon the sameworkerimage, always as a single replica, checked byhealthcheck-schedule. - ssr runs
inertia:start-ssr --runtime=bunon thessrimage. It sits only on the internal network and is health checked through its/healthendpoint. - nightwatch-agent uses the official
laravelphp/nightwatch-agent:v1image, also only on the internal network. The other services send their data to it on port 2409.
The parts I care about most:
- Two networks. Every service sits on a private
app-network. Only the services that need Traefik or the Coolify-managed Postgres and Redis also join the externalcoolifynetwork. The SSR server and the Nightwatch agent are not reachable from anything else on the server. - Hardening by default. Every container runs with
no-new-privilegesandcap_drop: ALL,/tmpis anoexectmpfs, and each service has memory limits and rotated logs. - Migrations run once. Only the
appcontainer has serversideup'sAUTORUN_ENABLED, andAUTORUN_LARAVEL_MIGRATION_ISOLATIONmakes sure that with several replicas only one of them runs the migrations. - Traefik does more than routing. Besides TLS, the labels add the HTTP to HTTPS redirect, compression, security headers (HSTS, frame deny, referrer and permissions policy), a health check on Laravel's
/uproute and sticky sessions for when there is more than one app replica. - SSR before web. The
appservice waits for the SSR server to be healthy and restarts with it, so the first requests after a deploy are already server-rendered.
Reverb is intentionally not part of this stack. I run it as a separate Coolify application on its own domain, so a redeploy of the app does not drop every WebSocket connection.
Some Drawbacks and Potential Pitfalls
Here are the real challenges I've run into:
- No rolling updates. Coolify does not support rolling updates for Docker Compose deployments. Every deploy recreates the containers, so expect a short window of downtime while the health checks pass.
- Build vs runtime environment variables. Separating the two is still not straightforward. My rule now is that only public
VITE_values go in as build arguments, and everything else, secrets included, comes from the runtime.envthat Coolify manages. - Dropping all capabilities has side effects. The FrankenPHP binary needs
NET_BIND_SERVICE. Withcap_drop: ALLand nothing added back, Octane fails to start with a confusingOperation not permittederror, which is why theappservice adds that one capability back. - Storage permissions. The storage volume must be writable by
www-data. Copying the source with--chown=www-data:www-datameans a fresh named volume starts out with the right ownership, but a volume created by an older setup may still need fixing by hand. - Traefik debugging. When a deployment is not reachable because of routing, Traefik's configuration and logs are not easy to get to from Coolify's UI. Debugging usually means SSH-ing into the server and inspecting Traefik directly. Forgetting
traefik.docker.network=coolifyon a service with two networks is the most common cause. - Database management. Upgrading databases that were set up through Coolify's one-click services (PostgreSQL, MySQL, Redis) is not well documented. Version upgrades or configuration changes require understanding Coolify's internal structure.
Conclusion
Coolify and Docker together give me a deployment setup I fully control, at the price of one server instead of a per-project subscription. The setup has grown from a single-stage image into separate web, worker and SSR images with hardened defaults, and it still deploys with a git push. The pitfalls are real, but for running several Laravel projects on my own infrastructure, the benefits clearly outweigh them.