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/releases10ARG TARGETARCH11RUN 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/supercronic17 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-npm11 12WORKDIR /var/www/html13 14COPY --chown=www-data:www-data . .15 16RUN composer install \17 --no-dev \18 --no-interaction \19 --no-scripts \20 --no-autoloader \21 --prefer-dist22 23RUN . "$NVM_DIR/nvm.sh" && npm ci && npm run build24 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/html14 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/app15 16 cron:17 build:18 context: .19 target: production20 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: 10s24 timeout: 5s25 retries: 326 volumes:27 - storage:/var/www/html/storage/app28 29 horizon:30 build:31 context: .32 target: production33 command: ["php", "/var/www/html/artisan", "horizon"]34 healthcheck:35 test: ["CMD", "healthcheck-horizon"]36 start_period: 10s37 volumes:38 - storage:/var/www/html/storage/app39 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.