About Blog Contact
 
 

RSS

Fri, Mar 27, 2026
5 min read

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

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.

1FROM serversideup/php:8.4-frankenphp AS base
2 
3USER root
4 
5RUN apt-get update -y \
6 && apt-get install postgresql-client -y \
7 && install-php-extensions bcmath intl gd exif
8 
9# Latest releases available at https://github.com/aptible/supercronic/releases
10ARG TARGETARCH
11RUN SUPERCRONIC_VERSION="v0.2.43" \
12 && SUPERCRONIC="supercronic-linux-${TARGETARCH}" \
13 && curl -fsSLO "https://github.com/aptible/supercronic/releases/download/${SUPERCRONIC_VERSION}/${SUPERCRONIC}" \
14 && chmod +x "$SUPERCRONIC" \
15 && mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
16 && ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
17 
18USER 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:

1FROM base AS build
2 
3USER root
4 
5ENV NVM_DIR="/root/.nvm"
6 
7RUN mkdir -p "$NVM_DIR" \
8 && curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh" | bash \
9 && . "$NVM_DIR/nvm.sh" \
10 && nvm install --lts --latest-npm
11 
12WORKDIR /var/www/html
13 
14COPY --chown=www-data:www-data . .
15 
16RUN composer install \
17 --no-dev \
18 --no-interaction \
19 --no-scripts \
20 --no-autoloader \
21 --prefer-dist
22 
23RUN . "$NVM_DIR/nvm.sh" && npm ci && npm run build
24 
25RUN 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:

1FROM base AS production
2WORKDIR /var/www/html
3 
4ENV SSL_MODE="off" \
5 PHP_OPCACHE_ENABLE="1" \
6 PHP_MEMORY_LIMIT="256M" \
7 AUTORUN_ENABLED="true" \
8 AUTORUN_LARAVEL_STORAGE_LINK="true" \
9 AUTORUN_LARAVEL_MIGRATION="true" \
10 AUTORUN_LARAVEL_MIGRATION_ISOLATION="true" \
11 HEALTHCHECK_PATH="/up"
12 
13COPY --chown=www-data:www-data --from=build /var/www/html /var/www/html
14 
15USER 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.

1services:
2 web:
3 build:
4 context: .
5 target: production
6 environment:
7 SSL_MODE: "off"
8 CADDY_LOG_FORMAT: "json"
9 PHP_OPCACHE_ENABLE: "1"
10 PHP_MEMORY_LIMIT: "256M"
11 AUTORUN_LARAVEL_STORAGE_LINK: "true"
12 HEALTHCHECK_PATH: "/up"
13 volumes:
14 - storage:/var/www/html/storage/app
15 
16 cron:
17 build:
18 context: .
19 target: production
20 entrypoint: ["supercronic", "-json", "-passthrough-logs", "-prometheus-listen-address", "0.0.0.0", "/var/www/html/cron"]
21 healthcheck:
22 test: ["CMD-SHELL", "curl -f http://127.0.0.1:9746/metrics || exit 1"]
23 interval: 10s
24 timeout: 5s
25 retries: 3
26 volumes:
27 - storage:/var/www/html/storage/app
28 
29 horizon:
30 build:
31 context: .
32 target: production
33 command: ["php", "/var/www/html/artisan", "horizon"]
34 healthcheck:
35 test: ["CMD", "healthcheck-horizon"]
36 start_period: 10s
37 volumes:
38 - storage:/var/www/html/storage/app
39 
40volumes:
41 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:

1* * * * * 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.

Legal Notice  |   Privacy  |   RSS  |   © 2026 christlieb.eu