I updated a handful of Coolify-hosted Laravel apps recently to get zero-downtime updates. Gone are those few annoying seconds of “no server available” I had been living with during deployments!

Old Layout

Previously, I had configured each app with the following resources in Coolify’s UI:

  1. Standalone database service (MySQL or PostgreSQL) added via UI. Easy backups, doesn’t have to restart for a deployment.
  2. Standalone Redis service, again added via UI so it can persist independently.
  3. docker-compose stack for the web server, scheduler, and queue. (Also Horizon and Reverb in some cases.)
Screenshot of the Coolify project’s Resources view showing the old layout: a standalone database, a standalone Redis service, and a single docker-compose stack bundling the web server, scheduler, and queue worker together.

The monolithic docker-compose stack is what needed to change:

# docker-compose.yaml (old layout)
# Coolify deploys this as one stack:
# Stops all containers, rebuilds, and starts; downtime during that window.
services:
  octane:
    build:
      context: .
      dockerfile: ./.docker/octane/Dockerfile
    command: ["php", "artisan", "octane:frankenphp"]
    volumes:
      - './storage:/var/www/html/storage'
    healthcheck:
      test: ["CMD-SHELL", "php artisan octane:status | grep 'server is running'"]
      interval: 10s
      retries: 5
      start_period: 10s
      timeout: 10s
  scheduler:
    build:
      context: .
      dockerfile: ./.docker/cli/Dockerfile
    command: ["php", "artisan", "schedule:work"]
    exclude_from_hc: true
    volumes:
      - ./storage:/var/www/html/storage
  queue:
    build:
      context: .
      dockerfile: ./.docker/cli/Dockerfile
    command: ["php", "artisan", "queue:work", "--tries=3"]
    exclude_from_hc: true
    volumes:
      - ./storage:/var/www/html/storage

I used one Dockerfile for the web server (octane above), and another CLI-only Dockerfile for the scheduler and queue. Both use Server Side Up images, which now have a FrankenPHP variant that’s perfect for Octane.

A nice thing about this was having environment variables easily shared across the whole stack. (Foreshadowing!)

New Layout

The new layout splits the web server out of its previous stack, so I end up with four Coolify resources instead of three:

  1. Standalone database (no change)
  2. Standalone Redis (no change)
  3. docker-compose stack for the scheduler and queue (no change)
  4. Dockerfile for the web server (new!)
Screenshot of the Coolify project’s Resources view showing the new layout: a standalone database, a standalone Redis service, a docker-compose stack for scheduler and queue worker, and a separate Dockerfile-based application for the web server.

The docker-compose stack got a little smaller:

# docker-compose.yaml (new layout; no web server)
services:
  scheduler:
    build:
      context: .
      dockerfile: ./.docker/cli/Dockerfile
    command: ["php", "artisan", "schedule:work"]
    exclude_from_hc: true
    volumes:
      - ./storage:/var/www/html/storage
  queue:
    build:
      context: .
      dockerfile: ./.docker/cli/Dockerfile
    command: ["php", "artisan", "queue:work", "--tries=3"]
    exclude_from_hc: true
    volumes:
      - ./storage:/var/www/html/storage

Those each use the CLI Dockerfile since they don’t need a web server:

# .docker/cli/Dockerfile
FROM serversideup/php:8.5-cli

ENV PHP_OPCACHE_ENABLE=1
ENV PHP_MEMORY_LIMIT=1024M

USER root

RUN install-php-extensions intl bcmath exif gd imagick

RUN apt-get update && \
    apt-get install -y zip unzip git && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/html

COPY --chown=www-data:www-data composer.json composer.lock ./

USER www-data

RUN --mount=type=cache,target=/root/.composer \
    composer install --prefer-dist --no-interaction --optimize-autoloader --no-dev

COPY --chown=www-data:www-data . /var/www/html

The web server uses its own Dockerfile:

# .docker/octane/Dockerfile
FROM serversideup/php:8.5-frankenphp

ENV PHP_OPCACHE_ENABLE=1
ENV AUTORUN_LARAVEL_MIGRATION=true

USER root

RUN install-php-extensions intl bcmath exif gd imagick

RUN apt-get update && \
    apt-get install -y zip unzip git && \
    curl -sL https://deb.nodesource.com/setup_22.x | bash - && \
    apt-get install -y nodejs && \
    rm -rf /var/lib/apt/lists/*

WORKDIR /var/www/html

COPY --chown=www-data:www-data composer.json composer.lock package*.json ./

USER www-data

RUN --mount=type=cache,target=/root/.composer \
    composer install --prefer-dist --no-interaction --optimize-autoloader --no-dev

RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline

COPY --chown=www-data:www-data . /var/www/html

RUN npm run build

Both of these cache dependencies to speed up deployment. The web server gets Node.js installed to build front-end assets, and it’s the one responsible for running Laravel migrations.

I added a new Private Repository resource using Coolify’s UI, pointed it to .docker/octane/Dockerfile, and set up a health check for Laravel’s /up endpoint on port 8080.

Automatic deployments now build the new web server instance and make sure it’s ready before switching traffic, so there’s no more dip in availability from the browser.

Shared Environment Variables

I copied the entire set of environment variables for the new web server resource, which felt bad. It’d be way too easy to make an update to the web server’s environment in the future and forget to apply it for the workers.

Coolify has a Shared Variables feature that makes this slightly less painful.

Rather than duplicate environment variables, I can create an “environment wide” set of shared variables for the project. That set looks like a normal list:

# (...)
DB_HOST=my-database-hostname
DB_PASSWORD=my-database-password
# (...)

I still have to create environment variables for each resource, but they can use placeholders like this:

# (...)
DB_HOST={{environment.DB_HOST}}
DB_PASSWORD={{environment.DB_PASSWORD}}
# (...)

As long as I remember to add keys and placeholders to each resource, I can change the values in one place and they’re picked up in subsequent deploys like normal.


This has been working seamlessly without any issues for five different apps!