Status: available for projects [email protected]
← Back to articles

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

  1. Consistent environments: every deployment is built from the same Dockerfile, which removes a whole class of "works on my machine" bugs.
  2. Small, purpose-built images: the web, worker and SSR containers each get only what they need.
  3. Scaling by environment variable: replicas for the app, the queue workers and SSR are set through APP_REPLICAS, QUEUE_WORKERS and SSR_REPLICAS.
  4. 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. web runs Octane on FrankenPHP and ships only the built assets. worker is a plain PHP CLI image for queues and the scheduler, without Bun or any frontend code. ssr is the only image that carries Bun and node_modules, because Vite's SSR bundle loads its dependencies at runtime.
  • Layer caching. Composer dependencies are installed from composer.json and composer.lock before 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:ssr run, 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 same RUN, so it never ends up in an image layer. The only build arguments are the public VITE_ 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:work with --max-time=3600 and --memory=400 on the worker image. The number of replicas comes from QUEUE_WORKERS, and serversideup's healthcheck-queue keeps an eye on it.
  • scheduler runs schedule:work on the same worker image, always as a single replica, checked by healthcheck-schedule.
  • ssr runs inertia:start-ssr --runtime=bun on the ssr image. It sits only on the internal network and is health checked through its /health endpoint.
  • nightwatch-agent uses the official laravelphp/nightwatch-agent:v1 image, 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 external coolify network. 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-privileges and cap_drop: ALL, /tmp is a noexec tmpfs, and each service has memory limits and rotated logs.
  • Migrations run once. Only the app container has serversideup's AUTORUN_ENABLED, and AUTORUN_LARAVEL_MIGRATION_ISOLATION makes 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 /up route and sticky sessions for when there is more than one app replica.
  • SSR before web. The app service 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:

  1. 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.
  2. 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 .env that Coolify manages.
  3. Dropping all capabilities has side effects. The FrankenPHP binary needs NET_BIND_SERVICE. With cap_drop: ALL and nothing added back, Octane fails to start with a confusing Operation not permitted error, which is why the app service adds that one capability back.
  4. Storage permissions. The storage volume must be writable by www-data. Copying the source with --chown=www-data:www-data means a fresh named volume starts out with the right ownership, but a volume created by an older setup may still need fixing by hand.
  5. 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=coolify on a service with two networks is the most common cause.
  6. 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.

Email me CV ↓