Install docker
Either regular docker installregular docker install or rootlessrootless.
Create a new directory
Create a new directory where you will store your Docker Compose file and PHP/html etc files. For example, you can create a directory called “phpapp” in your home directory:
mkdir -p ./phpappwrite a docker compose file
paste the below code in a new file called docker-compose.yml.
This defines two services: “caddy” and “phpapp”. The “caddy” service uses the Caddy v2 image and mounts the Caddyfile and public directory as volumes. It also exposes ports 80 and 443. The “phpapp” service uses the PHP-FPM imagePHP-FPM image from my GitHub repoGitHub repo (you can find other PHP versions along with other base operating systems (debian/alpine for example)) that’s updated weekly with the newest upstream code and adds many useful php extensions along with composer and mounts the public directory as a volume.
services: caddy: image: caddy:2-alpine container_name: caddy restart: unless-stopped volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./public:/srv - ./data:/data - ./caddy_config:/config ports: - "80:80" - "443:443" networks: - net
phpapp: image: ghcr.io/kingpin/php-docker:8.1-fpm-alpine container_name: phpapp restart: unless-stopped volumes: - ./public:/srv networks: - net
networks: net:write the Caddyfile
create a new file called Caddyfile in the same directory. This file defines the root directory as /srv and sets up a file server to serve files from that directory. It also sets up a FastCGI proxy to the PHP-FPM service on port 9000. This will also reach out to letsencrypt or zerossl and acquire an SSL cert for your domain example.com
example.com { root * /srv file_server php_fastcgi phpapp:9000}PHP test file
Create a new directory called “public” in the same directory as your docker-compose.yml file and add an index.php file with the following code:
<?phpphpinfo();?>Start the Docker container stack
Start the services by running the following command in your terminal:
docker compose up -d && docker compose logs -fThis will start the two services in your docker compose file and attach them to your terminal. You should see logs from both services.
Test in your browser
Open your web browser and navigate to http://example.com. You should see a PHP info page displayed, indicating that PHP-FPM is working correctly.
Move your code into the public folder
Remove the index.php file or rename it to phpinfo.php and put your php code in the public folder along with any html/js/css/etc files.
That’s it! You now have PHP-FPM and Caddy v2 running via Docker Compose. You can customize the Caddyfile and docker-compose.yml files to suit your needs, and add additional services as necessary.
Related Reading
- WordPress on PHP-FPM & Caddy in Docker
- Install Caddy reverse proxy via Docker
- Appwrite Backend-as-a-service (BaaS)
- How to install NextCloud via Docker
- NocoDB DB Management System
What actually breaks this setup
The happy path works great. Here’s what trips people up in practice.
PHP-FPM can’t reach your files
Both containers mount the same ./public:/srv volume — that’s the whole trick. If you see a blank page or a 502 from Caddy, the most common cause is that the phpapp container and the caddy container ended up with different volume mounts, or you moved your files somewhere other than public/. Check with:
docker compose exec phpapp ls /srvIf that comes back empty, your files aren’t where PHP-FPM is looking. Fix the volume path and docker compose restart phpapp.
502 Bad Gateway on first boot
Caddy starts fast. PHP-FPM takes a second longer to initialize the worker pool. If you hit the site in the first few seconds, Caddy can’t reach phpapp:9000 yet and you get a 502. It’ll clear on its own, but if you want to avoid it, add a healthcheck so Caddy waits:
phpapp: image: ghcr.io/kingpin/php-docker:8.1-fpm-alpine container_name: phpapp restart: unless-stopped healthcheck: test: ["CMD", "php-fpm", "-t"] interval: 10s timeout: 5s retries: 3 volumes: - ./public:/srv networks: - netTLS fails and Caddy just… doesn’t tell you
Caddy will silently fall back to HTTP if it can’t get a cert, which is useful locally but alarming in prod. Most common reasons:
- Port 443 is blocked on your host firewall or upstream router. Caddy needs both 80 and 443 reachable from the internet for the ACME challenge.
- Wrong domain —
example.comin the Caddyfile actually has to be your real domain, not a placeholder. Sounds obvious; doesn’t stop people. - Rate limited — Let’s Encrypt caps you at 50 certs per registered domain per week, but the one that’ll actually bite you here is the duplicate certificate limit: 5 per week for the exact same set of names. Spin this stack up and down a few times for the same domain and you’ll trip it. Use Caddy’s staging endpoint while you’re iterating:
{ acme_ca https://acme-staging-v02.api.letsencrypt.org/directory}
example.com { root * /srv file_server php_fastcgi phpapp:9000}Remove the global acme_ca block once everything’s working and you’re ready for the real cert.
Permissions headache with uploaded files
If your PHP app writes files (uploads, cache, sessions), the PHP-FPM process owns them as www-data (uid 82 on Alpine). If you then try to manage those files as your host user, you’ll get permission denied errors. You’ve got two options: run chown -R 82:82 ./public on the host, or add user: "1000:1000" to the phpapp service if your image supports it. The custom image from the repo handles this cleanly — check the README for the exact UID.