Atomic Coolify+Laravel Deployment
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:
- Standalone database service (MySQL or PostgreSQL) added via UI. Easy backups, doesn’t have to restart for a deployment.
- Standalone Redis service, again added via UI so it can persist independently.
- docker-compose stack for the web server, scheduler, and queue. (Also Horizon and Reverb in some cases.)

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/storageI 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:
- Standalone database (no change)
- Standalone Redis (no change)
- docker-compose stack for the scheduler and queue (no change)
- Dockerfile for the web server (new!)

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/storageThose 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/htmlThe 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 buildBoth 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!