Architecture decision for a PHP+JS Docker-based dev setup

1. Output of caddy version:

v2.6.1 h1:EDqo59TyYWhXQnfde93Mmv4FJfYe00dO60zMiEt+pzo=

2. How I run Caddy:

Inside a Docker container, wrapped in Rymfony (to have both Caddy and PHP running)

a. System environment:

Docker, all with debian:10-slim images

b. Command:

Rymfony launches Caddy in the container with this command:

/usr/local/bin/caddy run --watch --adapter caddyfile --config /home/.rymfony/.../Caddyfile.runtime

c. Service/unit/compose file:

Currently only the PHP container is relevant for this issue

services:
    php:
        build: ./docker/php/
        working_dir: /srv/
        ports: ['8000:8000']
        volumes: ['./backend/:/srv']
        command: ['rymfony', 'serve', '--document-root=/srv/public/', '--no-tls', '--port=8000']

d. My complete Caddy config:

{
    log {
        output file /home/.rymfony/.../log/http.server.log
    }
    auto_https off
}

http://127.0.0.1:8000 {
    root * /srv/public/

    encode gzip

    header -Server

    log {
        output file /home/.rymfony/.../log/http.vhost.log
    }

    php_fastcgi 127.0.0.1:60000 {
        env SERVER_SOFTWARE "Rymfony/Caddy"
        index index.php
        resolve_root_symlink
    }

    file_server
}

3. The problem I’m having:

It’s mostly an architecture question, see below after the template.

4. Error messages and/or full log output:

N/A

5. What I already tried:

All the above code (+ what’s in the linked repository)

6. Links to relevant resources:

Note: the project is there: GitHub - Pierstoval/php-js-boilerplate
It contains only boilerplate Symfony and Svelte Kit applications.


So, here it is: I have a dilemma. I’m creating a public template for an app I’ll develop, and I’m torn between “caddy-ing” everything, or using caddy only for my backend app.

The Docker Compose setup exposes frontend app to port 3000, and backend app to port 8000 using Rymfony (which uses Caddy in the background, see the runtime caddy config above)

It works, BUT, I’m wondering whether I should have one Caddy container and reverse_proxy to the frontend container and use php_fastcgi to the backend container.

I don’t know (yet) how to do that, but I’ll check anyway (maybe 2 caddy hosts with 2 different http ports I guess).

As for production, the Svelte app will be built into static files only, so production will be quite different since only the “file_server” Caddy instruction will be enough (with http caching I guess). And I will certainly not use Docker for prod anyway (just Caddy + PHP-FPM). So I can’t even think about “prod-ready” setup since this will only be for development.

For now, I mostly wonder which strategy is good:

  • Caddy only for backend/PHP (by using Rymfony like now) and Node/Vite for frontend (like now)
  • One Caddy to rule them all, and in the proxy to bring them

Which strategy would you rather recommend? :sweat_smile:

1 Like

(I appreciate your Lord of the Rings reference)

Go with strategy 2 (proxy from a single caddy instance). Simpler is better. If you don’t need Docker, I say drop it.

Your static site will be well-served by file_server, probably even without caching. (Only use caching if you need it, as it can make things quite complex.) Then reverse proxy to your backend. One Caddy instance. :one: :+1:

Docker is a much easier way to set up a dev environment. There’s no reason to toss it if it makes your life easier, to set up your and your coworker’s environments. Easier to stay on a specific version of PHP, etc.

But yeah, you don’t need more than one Caddy instance, most of the time.

2 Likes

Agreed, but I need it: PHP with extensions, database (postgres here), http, and nodejs. I can have them on my machine, but it is much easier to handle the version directly in the Docker image.


I will listen to your suggestions, I will gather them all around their Caddy master :grin:

1 Like

Okay, I’m back with news!

I decided to create 3 containers: php, caddy, nodejs

The goal is for Caddy to be able to (in dev only) proxy all http requests to the proper container.

Here is what I have:

localhost {
	log

	@api expression `(
		header({'Accept': '*application/json*'})
		|| header({'Accept': '*application/ld+json*'})
		|| path_regexp('^/(api|_profiler|_wdt|_error)')
	)`
	@frontend expression `(
		!header({'Accept': '*application/json*'})
		&& !header({'Accept': '*application/ld+json*'})
		&& !path_regexp('^/(api|_profiler|_wdt|_error)')
	)`

	root * /srv/public

	file_server
	php_fastcgi @api php:9000
	reverse_proxy @frontend http://node:3000

	encode zstd gzip
}

Pretty generic, I’d say. The path_regexp() are mostly used for Symfony and Api Platform.

My question is:

Even though I have a file_server statement, I’d like to do something similar than the try_files directive in nginx:

  • First, try to serve the file
  • If file does not exist, execute the PHP proxy if it matches the @api matcher
  • Else, use the reverse_proxy as fallback.

The current experience is that if the @api matcher is matched, it’ll be executed, and else the @frontend will be used.
It is just like if the file_server was not found at all, even when I am sure that the file exists.

I checked at the directives-order but apart using routes (for which I’m not sure about the order since they come before the directives that I’d like to use prior to routes), I am not sure of what to do here.

I think my brain is a bit tired at almost midnight but maybe you can guide me? :smile:

FYI you can simplify this:

To:

header({'Accept': ['*application/json*', '*application/ld+json*']})

You’re looking for the file matcher, then. But php_fastcgi already does this for you. Read the php_fastcgi docs’ expanded form which will explain how this works.

The file_server directive doesn’t fall-through by default (but can be configured to do so). It’s ordered near the end, because typically you match requests with other handlers and then let it fall through to file_server otherwise. That’s what php_fastcgi does, it uses the file matcher to check if the request path matches a file, and if it does it rewrites to it. For anything else, it rewrites to index.php which then gets proxied over fastcgi to your php-fpm service.

All that said, this is probably the config you want:

localhost {
	root * /srv/public

	log
	encode zstd gzip

	@frontend `(
		!header({'Accept': ['*application/json*', '*application/ld+json*']})
		&& !path_regexp('^/(api|_profiler|_wdt|_error)')
		&& !file('{path}')
	)`
	handle @frontend {
		reverse_proxy node:3000
	}

	handle {
		php_fastcgi php:9000
		file_server
	}
}

I added file to your @frontend matcher, which checks if the request path is a file that exists on disk. If it doesn’t, then it should read your node app. If it does exist, then it should fall through to the other handle with php_fastcgi/file_server

1 Like

I raw tested your example, and for some reason it doesn’t work.

Here’s the test:

In the Symfony app, there are assets that are located in the public/ directory, that should be available through HTTP, and there’s a volume in Caddy that works:

❯ docker compose exec caddy ls -lh /srv/public/bundles/apiplatform/init-swagger-ui.js
-rw-rw-rw-    1 1000     1000        5.7K Oct 20 21:45 /srv/public/bundles/apiplatform/init-swagger-ui.js

So the file does exist in the path.

However, when I curl it, it behaves like this:

❯ curl -Ik https://localhost/bundles/apiplatform/init-swagger-ui.js                                                                                                                                        13:23:16
HTTP/2 404
access-control-allow-origin: *
alt-svc: h3=":443"; ma=2592000
content-type: text/html
date: Fri, 21 Oct 2022 11:23:21 GMT
etag: "15eer5s"
server: Caddy
x-sveltekit-page: true

This means that the @frontend is matched anyway :open_mouth:

I even tried to use the file matcher differently, just to check if (and how) it works, here’s a test caddyfile:

localhost {
	log
	root * /srv/public
	@file_exists `file('{path}')`
	handle @file_exists {
		file_server
	}
	handle {
		respond "Noop"
	}
}

IIUC, this means that if the file exists, it’s rendered through file_server, and if not, the last handle will respond with an HTTP 200 saying Noop.

And when I curl it:

❯ curl -ik https://localhost/bundles/apiplatform/init-swagger-ui.js                                                                                                                                        13:25:05
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/plain; charset=utf-8
server: Caddy
content-length: 4
date: Fri, 21 Oct 2022 11:25:10 GMT

Noop

And the file exists, I checked in the filesystem: it’s readable, it’s in the right path.

I activated debug, but the only logs I get if I remove the TLS handshake is the following:

{"level":"info","ts":1666351631.2061439,"logger":"http.log.access","msg":"handled request","request":{"remote_ip":"172.21.0.1","remote_port":"51190","proto":"HTTP/2.0","method":"GET","host":"localhost","uri":"/bundles/apiplatform/init-swagger-ui.js","headers":{"User-Agent":["curl/7.68.0"],"Accept":["*/*"]},"tls":{"resumed":false,"version":772,"cipher_suite":4865,"proto":"h2","server_name":"localhost"}},"user_id":"","duration":0.0000852,"size":4,"status":200,"resp_headers":{"Server":["Caddy"],"Alt-Svc":["h3=\":443\"; ma=2592000"],"Content-Type":["text/plain; charset=utf-8"]}}

I surely have missed something, but can’t figure out what :thinking:

EDIT: Calls to the PHP API and the Node backend are working though, only the requests to the files that should exist in the caddy container don’t work

I did more investigation and tests, and I have a working setup!

Here is the final Caddyfile:

{
	# Debug
	debug
}

localhost {
	log
	root * /srv/public

	@file_exists `file({path}) && !path_regexp(".php")`

	handle @file_exists {
		file_server
	}

	@api `(
		header({'Accept': ['*application/json*', '*application/ld+json*']})
		|| path_regexp('(.php|^/(api|_profiler|_wdt|_error))')
	)`

	handle @api {
		php_fastcgi php:9000
	}

	handle {
		reverse_proxy node:3000
	}
}

Thanks a lot @francislavoie for your help, the details about the file() matcher and the way expressions work was really helpful! :tada:

2 Likes

Ah right, my bad, shouldn’t have quotes around the placeholder I guess. The placeholder gets expanded into a function call when compiled, so wrapping in quotes makes the function call text itself become the string. I think.

1 Like

This topic was automatically closed after 30 days. New replies are no longer allowed.