Using caddy for local development (on macOS)

This article wasn't updated in the last 1 ¾ years. Please double check if the content is still up-to-date.

If you find any error, please send me a quick heads-up.

Caddy is a modern web server with great defaults – so let’s use it for local development as well. This guide is for installing it on macOS.

Install Caddy

First install caddy via Homebrew:

brew install caddy

One thing you need to do once is to install after installation is trusting the root certificate.

Caddy has automatic TLS provisioning built-in – without configuration. That works for public domains (via Let’s Encrypt), but also for local and internal domains.
Caddy generates a root certificate and derives from it certificates for your local dev domains. But for your browser to not complain about these certificate, you must install the root certificate as trusted in your system once.

As the following call interacts with macOS internals, we need to call it with sudo:

sudo caddy trust

Now the installation is finished.

Please note that this is one of the few times we interact with the caddy executable directly. To start / stop / restart it later, we will use brew services.

Starting & stopping

Caddy will be started / stopped / restarted using brew services:

brew services start caddy
brew services stop caddy
brew services restart caddy

While it is possible to reload the configuration changes, you can just restart it every time – the start up time is really fast, so don’t bother. 😄

Configuration

The configuration of caddy is at the config directory of your homebrew installation. That is either

  • /opt/homebrew/etc/Caddyfile for M1 macs
  • /usr/local/etc/Caddyfilee for Intel macs

Edit this file in your favorite editor (there is a nice VS Code extension for syntax highlighting).

Example configs

Make yourself familiar with the options, the syntax and structure of a Caddyfile. What now follows are simple base configurations for different project types:

PHP project with single entry (like Symfony, etc…)

This config includes:

  • connection PHP-FPM via unix socket
  • a file server to serve static files
  • TLS support
  • The path to the document root
symfony.example.test {

	tls internal
	root * /var/www/symfony-project/public

	file_server {
		index index.php index.html
	}

	php_fastcgi unix//opt/homebrew/var/run/php8.1-fpm.sock
}

Please note the tls internal:As of Nov 2022 Caddy doesn’t recognize .test as a private TLD and doesn’t automatically use local TLS for it.

PHP project with a different entry point

This is basically the same config as above (and uses a different PHP version):

other-entry.example.test {
	tls internal
	root * /var/www/other-entry/public

	file_server {
		index app_dev.php
	}

	php_fastcgi unix//opt/homebrew/var/run/php7.4-fpm.sock {
		index app_dev.php
	}
}

Automatic projects by subdomain

One neat setup is having all .test domain mapped locally (setup is described in the article about dnsmasq), putting all your project in a single directory and automatically mapping sub domains to your project directories.

In this example I assume every project is a project with a public/ directory as entry and with public/index.php as single entry point:

*.projects.test {
	tls internal
	root * /var/projects/{http.request.host.labels.2}/public
	file_server {
		index index.php index.html
	}
	php_fastcgi unix//opt/homebrew/var/run/php8.1-fpm.sock
}

This setup is basically the same as above, except for two parts:

  • The domain is *.projects.test: this automatically resolves all subdomains.
  • The segment in the path {http.request.host.labels.2}

Caddy automatically parses the hostname for you and separates it into labels. These labels are numbered right to left, beginning with 0:

sub-sub-domain.sub-domain.example.test
╰─────┬──────╯ ╰───┬────╯ ╰──┬──╯ ╰┬─╯
      3            2         1     0

Overwriting the automatic setup for specific domains

If you now have a project that needs different config (like PHP 8.0), you can just add a server block with a specific domain – it will have priority above the one with the wildcard:

*.projects.test {
	# ...
}

old.projects.test {
	tls internal
	# you can keep using the placeholder, no need to hardcode "old" here
	# but you can of course change the path here as well
	root * /var/projects/{http.request.host.labels.2}/public
	file_server {
		index index.php index.html
	}
	php_fastcgi unix//opt/homebrew/var/run/php8.0-fpm.sock
}

That’s it!