March 27, 2026

How I Host christlieb.eu with Docker, Coolify, and GitHub

By Manuel Christlieb — Staff Engineer

In my last post, I mentioned moving to Hetzner and Coolify for hosting. What I didn’t go into was the actual setup — the Dockerfile, the Compose file, how Coolify ties it all together. This post fills that gap.

I’ll walk through the exact configuration I use to deploy christlieb.eu. Nothing hypothetical — this is the real thing, running in production right now.


The Stack

Before diving into files, here’s what we’re working with:

  • FrankenPHP via serversideup/php — a single runtime that replaces the classic PHP-FPM + Nginx combo
  • Supercronic — a cron replacement built for containers, with JSON logging and Prometheus metrics
  • Laravel Horizon — for queue management
  • Coolify — self-hosted PaaS on a Hetzner VPS, handles deployments from GitHub
  • PostgreSQL — because I got tired of MySQL’s quirks

I used to run PHP-FPM and Nginx as separate processes. FrankenPHP simplifies that into a single binary — one less thing to configure, one less thing to break.


The Dockerfile

The Dockerfile has three stages: a base image with system dependencies, a build stage for Composer and frontend assets, and a production stage that copies the result into a clean image.

FROM serversideup/php:8.4-frankenphp AS base

USER root

RUN apt-get update -y \
    && apt-get install postgresql-client -y \
    && install-php-extensions bcmath intl gd exif

# Latest releases available at https://github.com/aptible/supercronic/releases
ARG TARGETARCH
RUN SUPERCRONIC_VERSION="v0.2.43" \
 && SUPERCRONIC="supercronic-linux-${TARGETARCH}" \
 && curl -fsSLO "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/${SUPERCRONIC}" \
 && chmod +x "$SUPERCRONIC" \
 && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
 && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic

USER www-data

A few things worth noting. The TARGETARCH build argument lets Docker pick the right Supercronic binary depending on the platform — ARM64 on my Mac, x86_64 on the Hetzner server. No more hardcoding architectures.

PostgreSQL client is there so I can run pg_dump from inside the container if I ever need to. Extensions like intl and gd are standard Laravel requirements.

Next, the build stage:

FROM base AS build

USER root

ENV NVM_DIR="/root/.nvm"

RUN mkdir -p "$NVM_DIR" \
    && curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh" | bash \
    && . "$NVM_DIR/nvm.sh" \
    && nvm install --lts --latest-npm

WORKDIR /var/www/html

COPY --chown=www-data:www-data . .

RUN composer install \
    --no-dev \
    --no-interaction \
    --no-scripts \
    --no-autoloader \
    --prefer-dist

RUN . "$NVM_DIR/nvm.sh" && npm ci && npm run build

RUN composer dump-autoload --optimize --no-dev

I install NVM and Node in the build stage because I don’t want Node anywhere near the production image. Composer runs in two steps — first install without autoloader or scripts, then dump an optimized autoloader after the frontend build. This keeps the layer cache useful.

Finally, the production stage is minimal:

FROM base AS production
WORKDIR /var/www/html

ENV SSL_MODE="off" \
    PHP_OPCACHE_ENABLE="1" \
    PHP_MEMORY_LIMIT="256M" \
    AUTORUN_ENABLED="true" \
    AUTORUN_LARAVEL_STORAGE_LINK="true" \
    AUTORUN_LARAVEL_MIGRATION="true" \
    AUTORUN_LARAVEL_MIGRATION_ISOLATION="true" \
    HEALTHCHECK_PATH="/up"

COPY --chown=www-data:www-data --from=build /var/www/html /var/www/html

USER www-data

AUTORUN_LARAVEL_MIGRATION is a serversideup feature — it runs php artisan migrate --force on container start. One less thing to think about during deploys. SSL_MODE=off because Coolify handles TLS termination via its built-in Caddy reverse proxy.


Docker Compose

Three services, one shared storage volume. All three use the same production target — the difference is just the entrypoint.

services:
  web:
    build:
      context: .
      target: production
    environment:
      SSL_MODE: "off"
      CADDY_LOG_FORMAT: "json"
      PHP_OPCACHE_ENABLE: "1"
      PHP_MEMORY_LIMIT: "256M"
      AUTORUN_LARAVEL_STORAGE_LINK: "true"
      HEALTHCHECK_PATH: "/up"
    volumes:
      - storage:/var/www/html/storage/app

  cron:
    build:
      context: .
      target: production
    entrypoint: ["supercronic", "-json", "-passthrough-logs", "-prometheus-listen-address", "0.0.0.0", "/var/www/html/cron"]
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://127.0.0.1:9746/metrics || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 3
    volumes:
      - storage:/var/www/html/storage/app

  horizon:
    build:
      context: .
      target: production
    command: ["php", "/var/www/html/artisan", "horizon"]
    healthcheck:
      test: ["CMD", "healthcheck-horizon"]
      start_period: 10s
    volumes:
      - storage:/var/www/html/storage/app

volumes:
  storage:

The web service runs FrankenPHP — no command override needed, it just serves HTTP by default. The cron service runs Supercronic with JSON logging and a Prometheus metrics endpoint on port 9746, which Coolify can use for health checks. And horizon manages the queue workers.

All three share a storage volume so uploaded files are accessible from every container. This matters when a user uploads something through the web service and a queued job needs to process it in the horizon container.

The cron file itself is just one line:

* * * * * cd /var/www/html && php artisan schedule:run

Nothing fancy. Laravel’s scheduler handles the rest.


Setting Up Coolify

If you haven’t used Coolify before — it’s a self-hosted alternative to platforms like Railway or Render. I run it on a Hetzner VPS and it handles deployments, SSL certificates, databases, and container management through a clean web UI.

Here’s the rough setup process:

1. Create a project and add a database

In Coolify’s dashboard, create a new project and add a PostgreSQL resource. Note the internal connection details — host, port, database name, credentials. You’ll need these for the environment variables.

2. Connect your GitHub repository

Add a new resource, pick “Docker Compose”, and connect it to your GitHub repo via a GitHub App. Coolify will pull your code on every push to main.

Point it to compose.yml as the Docker Compose file. Coolify reads this and knows exactly which services to build and run.

3. Configure environment variables

This is where you set everything Laravel needs — APP_KEY, APP_ENV=production, APP_URL, your database credentials, Redis connection, mail config, whatever your app requires.

The database host is the internal hostname Coolify assigns to your PostgreSQL resource. It’s not localhost — it’s something like your-db-resource-name on Coolify’s internal Docker network.

4. Deploy

Hit deploy. Coolify pulls the code, builds the images from your Dockerfile, and starts the containers as defined in compose.yml. Migrations run automatically on the web container start. If something breaks, Coolify keeps the previous version running until the new one passes its health check.


What I Like About This Setup

It’s boring — and I mean that as a compliment. There’s no CI/CD pipeline to maintain, no custom deploy scripts, no Kubernetes manifesting itself in my nightmares. Push to main, Coolify builds and deploys. That’s it.

The Dockerfile produces a single image that all three services share. The Compose file is 40 lines. The cron file is one line. When something breaks, there aren’t many places to look.

If you’re running a personal project or a small production app and you don’t want to deal with managed hosting costs or platform lock-in, this stack works. It’s been running christlieb.eu without issues, and I haven’t had to touch the deployment config in months.

If you have questions or want to compare notes on your own setup, feel free to reach out.