Using Caddy as non-root: failed to install root certificate

1. The problem I’m having:

I’m trying to use Caddy with non-root privileges in a Docker-Environment, which lead to some permission problems. Some of them I already fixed. The current issue I’m trying so solve is the error, which can be seen in the log-output below. So I have two questions: Firstly, where is the corresponding section of code, which produces this error, so that I can debug the error and maybe following errors. And secondly, what could I try to do, to resolve the current error (but firstly is more important to me).

My Current Setup:
I’m using a modified symfony-docker template, which uses in turn frankenphp. However this is by default starting Caddy as root, which I changed.

Maybe I should not reach to the caddy community, but rather to the frankenphp community. But I feel somehow, that I’ll find a solution to the current problem rather here. If I’m wrong, please tell me.

2. Error messages and/or full log output:

The docker container runs a version of debian 12, which seems not to have systemd installed, or at least no journalctl command. So the following is the output of the docker log.

Excerpt of Log with Error:

ERROR	pki.ca.local	failed to install root certificate	{"error": "failed to execute tee: exit status 1", "certificate_file": "storage:pki/authorities/local/root.crt"}

Rest of Log

dashboard_php_db   | 2025-01-03 11:16:19+00:00 [Note] [Entrypoint]: MariaDB upgrade not required
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] Starting MariaDB 11.6.1-MariaDB-ubu2404 source revision 05fe3f1c186a221c4455b4d83a9d59f09f2dfadb server_uid Vd/ZhQ9/1UDIkuJiKG/mWrlg94k= as process 1
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Compressed tables use zlib 1.3
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Number of transaction pools: 1
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Using crc32 + pclmulqdq instructions
dashboard_php_db   | 2025-01-03 11:16:19 0 [Warning] mariadbd: io_uring_queue_init() failed with errno 0
dashboard_php_db   | 2025-01-03 11:16:19 0 [Warning] InnoDB: liburing disabled: falling back to innodb_use_native_aio=OFF
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Initializing buffer pool, total size = 128.000MiB, chunk size = 2.000MiB
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Completed initialization of buffer pool
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: File system buffers for log disabled (block size=512 bytes)
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: End of log at LSN=231419
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Opened 3 undo tablespaces
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: 128 rollback segments in 3 undo tablespaces are active.
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Setting file './ibtmp1' size to 12.000MiB. Physically writing the file full; Please wait ...
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: File './ibtmp1' size is now 12.000MiB.
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: log sequence number 231419; transaction id 231
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] Plugin 'FEEDBACK' is disabled.
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Loading buffer pool(s) from /var/lib/mysql/ib_buffer_pool
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] Plugin 'wsrep-provider' is disabled.
dashboard_php_db   | 2025-01-03 11:16:19 0 [Note] InnoDB: Buffer pool(s) load completed at 250103 11:16:19
dashboard_php_app  | 2025/01/03 11:16:21.795	INFO	using config from file	{"file": "/etc/caddy/Caddyfile"}
dashboard_php_app  | 2025/01/03 11:16:21.799	INFO	adapted config to JSON	{"adapter": "caddyfile"}
dashboard_php_app  | 2025/01/03 11:16:21.799	WARN	Caddyfile input is not formatted; run 'caddy fmt --overwrite' to fix inconsistencies	{"adapter": "caddyfile", "file": "/etc/caddy/Caddyfile", "line": 12}
dashboard_php_app  | 2025/01/03 11:16:21.802	INFO	admin	admin endpoint started	{"address": "localhost:2019", "enforce_origin": false, "origins": ["//[::1]:2019", "//127.0.0.1:2019", "//localhost:2019"]}
dashboard_php_app  | 2025/01/03 11:16:21.803	INFO	http.auto_https	server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS	{"server_name": "srv0", "https_port": 443}
dashboard_php_app  | 2025/01/03 11:16:21.803	INFO	http.auto_https	enabling automatic HTTP->HTTPS redirects	{"server_name": "srv0"}
dashboard_php_app  | 2025/01/03 11:16:21.803	WARN	http.auto_https	server is listening only on the HTTP port, so no automatic HTTPS will be applied to this server	{"server_name": "srv1", "http_port": 80}
dashboard_php_app  | 2025/01/03 11:16:21.803	INFO	tls.cache.maintenance	started background certificate maintenance	{"cache": "0xc00078ef80"}
dashboard_php_app  | 2025/01/03 11:16:21.804	WARN	http.handlers.mercure	Setting the transport_url or the MERCURE_TRANSPORT_URL environment variable is deprecated, use the "transport" directive instead
dashboard_php_app  | 2025/01/03 11:16:21.804	WARN	http.handlers.mercure	Setting the transport_url or the MERCURE_TRANSPORT_URL environment variable is deprecated, use the "transport" directive instead
dashboard_php_app  | 2025/01/03 11:16:21.805	WARN	http.handlers.mercure	Setting the transport_url or the MERCURE_TRANSPORT_URL environment variable is deprecated, use the "transport" directive instead
dashboard_php_app  | 2025/01/03 11:16:21.806	WARN	http.handlers.mercure	Setting the transport_url or the MERCURE_TRANSPORT_URL environment variable is deprecated, use the "transport" directive instead
dashboard_php_app  | 2025/01/03 11:16:21.807	WARN	http.handlers.mercure	Setting the transport_url or the MERCURE_TRANSPORT_URL environment variable is deprecated, use the "transport" directive instead
dashboard_php_app  | 2025/01/03 11:16:21.808	WARN	http.handlers.mercure	Setting the transport_url or the MERCURE_TRANSPORT_URL environment variable is deprecated, use the "transport" directive instead
dashboard_php_app  | 2025/01/03 11:16:21.808	WARN	pki.ca.local	installing root certificate (you might be prompted for password)	{"path": "storage:pki/authorities/local/root.crt"}
dashboard_php_app  | 2025/01/03 11:16:21.809	INFO	warning: "certutil" is not available, install "certutil" with "apt install libnss3-tools" or "yum install nss-tools" and try again
dashboard_php_app  | 2025/01/03 11:16:21.809	INFO	define JAVA_HOME environment variable to use the Java trust
dashboard_php_app  | 2025/01/03 11:16:21.811	ERROR	pki.ca.local	failed to install root certificate	{"error": "failed to execute tee: exit status 1", "certificate_file": "storage:pki/authorities/local/root.crt"}
dashboard_php_app  | 2025/01/03 11:16:21.811	INFO	http.log	server running	{"name": "srv1", "protocols": ["h1", "h2", "h3"]}
dashboard_php_app  | 2025/01/03 11:16:21.812	INFO	http	enabling HTTP/3 listener	{"addr": ":443"}
dashboard_php_app  | 2025/01/03 11:16:21.812	INFO	failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB). See https://github.com/quic-go/quic-go/wiki/UDP-Buffer-Sizes for details.
dashboard_php_app  | 2025/01/03 11:16:21.812	INFO	http.log	server running	{"name": "srv0", "protocols": ["h1", "h2", "h3"]}
dashboard_php_app  | 2025/01/03 11:16:21.812	INFO	http	enabling automatic TLS certificate management	{"domains": ["localhost"]}
dashboard_php_app  | 2025/01/03 11:16:21.812	WARN	tls	stapling OCSP	{"error": "no OCSP stapling for [localhost]: no OCSP server specified in certificate", "identifiers": ["localhost"]}
dashboard_php_app  | 2025/01/03 11:16:21.813	INFO	tls	storage cleaning happened too recently; skipping for now	{"storage": "FileStorage:/data/caddy", "instance": "f721fee5-453d-4477-8932-b28bd2318b74", "try_again": "2025/01/04 11:16:21.813", "try_again_in": 86399.99999964}
dashboard_php_app  | 2025/01/03 11:16:21.813	INFO	tls	finished cleaning storage units
dashboard_php_app  | 2025/01/03 11:16:21.834	INFO	FrankenPHP started 🐘	{"php_version": "8.3.15", "num_threads": 8}
dashboard_php_app  | 2025/01/03 11:16:21.835	INFO	autosaved config (load with --resume flag)	{"file": "/config/caddy/autosave.json"}
dashboard_php_app  | 2025/01/03 11:16:21.835	INFO	serving initial configuration
dashboard_php_app  | 2025/01/03 11:16:21.838	INFO	watcher	watching config file for changes	{"config_file": "/etc/caddy/Caddyfile"}

3. Caddy version:

I didn’t find out, where caddy was installed in the docker container. However the output of frankenphp --version is:
FrankenPHP v1.3.6 PHP 8.3.15 Caddy v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=

4. How I installed and ran Caddy:

a. System environment:

The content of /etc/os-release is:

PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

b. Command:

I have to admit, that I do not know exactly how caddy is run by frankenphp under the hood.

c. Service/unit/compose file:

compose.yaml

services:
  php:
    container_name: $DASHBOARD_PHP_APP_CONTAINER_NAME
    image: $DOCKER_IMAGE_REPOSITORY/php:$APP_VERSION
    restart: unless-stopped
    environment:
      SERVER_NAME: ${SERVER_NAME:-localhost}, php:80
      MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!...!}
      MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!...!}
      # Run "composer require symfony/orm-pack" to install and configure Doctrine ORM
      DATABASE_URL: $MARIADB_URL
      # Run "composer require symfony/mercure-bundle" to install and configure the Mercure integration
      MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure}
      MERCURE_PUBLIC_URL: ${CADDY_MERCURE_PUBLIC_URL:-https://${SERVER_NAME:-localhost}/.well-known/mercure}
      MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!...!}
      # The two next lines can be removed after initial installation
      SYMFONY_VERSION: ${SYMFONY_VERSION:-}
      STABILITY: ${STABILITY:-stable}
    volumes:
      - ./volumes/caddy/data:/data
      - ./volumes/caddy/config:/config
      - ./volumes/var:/app/var
      - ./frankenphp/docker-entrypoint.sh:/usr/local/bin/docker-entrypoint
      - ./proxychains4.conf:/etc/proxychains4.conf
      #- caddy_data:./caddy/data
      #- caddy_config:./caddy/config
    ports:
      # HTTP
      - target: 80
        published: ${HTTP_PORT:-80}
        protocol: tcp
      # HTTPS
      - target: 443
        published: ${HTTPS_PORT:-443}
        protocol: tcp
      # HTTP/3
      - target: 443
        published: ${HTTP3_PORT:-443}
        protocol: udp
      # Websocket
      - "3001:3001"

compose.override.yaml

services:
  php:
    build:
      context: .
      target: frankenphp_dev
      args:
          MANAGER_UID: $MANAGER_UID
          MANAGER_GID: $MANAGER_GID
    volumes:
      - ./:/app
      - ./frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro
      - ./frankenphp/conf.d/20-app.dev.ini:/usr/local/etc/php/app.conf.d/20-app.dev.ini:ro
      # If you develop on Mac or Windows you can remove the vendor/ directory
      #  from the bind-mount for better performance by enabling the next line:
      #- /app/vendor
    environment:
      MERCURE_EXTRA_DIRECTIVES: demo
      # See https://xdebug.org/docs/all_settings#mode
      XDEBUG_MODE: "${XDEBUG_MODE:-off}"
    extra_hosts:
      # Ensure that host.docker.internal is correctly defined on Linux
      - host.docker.internal:host-gateway
    tty: true

Dockerfile

#syntax=docker/dockerfile:1.4

# Versions
FROM dunglas/frankenphp:1-php8.3 AS frankenphp_upstream

# The different stages of this Dockerfile are meant to be built into separate images
# https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage
# https://docs.docker.com/compose/compose-file/#target


# Base FrankenPHP image
FROM frankenphp_upstream AS frankenphp_base

WORKDIR /app

# persistent / runtime deps
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends \
	acl \
	file \
	gettext \
	git \
    proxychains4 \
	&& rm -rf /var/lib/apt/lists/*

# supported extensions:
# https://github.com/mlocati/docker-php-extension-installer?tab=readme-ov-file#supported-php-extensions
RUN set -eux; \
	install-php-extensions \
		@composer \
		apcu \
		intl \
		opcache \
		zip \
        pcntl \
        pdo_mysql \
        ds \
        ldap \
	;

ENV PHP_INI_SCAN_DIR=":$PHP_INI_DIR/app.conf.d"

###> recipes ###
###> doctrine/doctrine-bundle ###
RUN install-php-extensions pdo_pgsql
###< doctrine/doctrine-bundle ###
###< recipes ###

COPY --link frankenphp/conf.d/10-app.ini $PHP_INI_DIR/app.conf.d/
COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint
COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile

ENTRYPOINT ["docker-entrypoint"]

HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1
CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ]

# Dev FrankenPHP image
FROM frankenphp_base AS frankenphp_dev

ARG MANAGER_UID
ARG MANAGER_GID

ENV APP_ENV=dev XDEBUG_MODE=off

RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"

RUN set -eux; \
	install-php-extensions \
		xdebug \
	;

COPY --link frankenphp/conf.d/20-app.dev.ini $PHP_INI_DIR/app.conf.d/

#RUN groupadd -g $manager_gid manager; \
#    useradd -u $manager_uid -g $manager_gid manager;

RUN groupmod -g $MANAGER_GID www-data; \
    usermod -u $MANAGER_UID -g $MANAGER_GID www-data;

RUN chown -R www-data:www-data /data

USER www-data

CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ]

# Prod FrankenPHP image

# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
# ENV COMPOSER_ALLOW_SUPERUSER=1

FROM frankenphp_base AS frankenphp_prod

ENV APP_ENV=prod
ENV FRANKENPHP_CONFIG="import worker.Caddyfile"

RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"

COPY --link frankenphp/conf.d/20-app.prod.ini $PHP_INI_DIR/app.conf.d/
COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile

# prevent the reinstallation of vendors at every changes in the source code
COPY --link composer.* symfony.* ./
RUN set -eux; \
	composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress

# copy sources
COPY --link . ./
RUN rm -Rf frankenphp/

VOLUME /app/var/

RUN set -eux; \
	mkdir -p var/cache var/log; \
	composer dump-autoload --classmap-authoritative --no-dev; \
	composer dump-env prod; \
	composer run-script --no-dev post-install-cmd; \
	chmod +x bin/console; sync;

d. My complete Caddy config:

Caddyfile

{
	{$CADDY_GLOBAL_OPTIONS}

	frankenphp {
		{$FRANKENPHP_CONFIG}
	}
}

{$CADDY_EXTRA_CONFIG}

(symfony) {
    root * /app/public
    encode zstd br gzip

    mercure {
        # Transport to use (default to Bolt)
        transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db}
        # Publisher JWT key
        publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG}
        # Subscriber JWT key
        subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG}
        # Allow anonymous subscribers (double-check that it's what you want)
        anonymous
        # Enable the subscription API (double-check that it's what you want)
        subscriptions
        # Extra directives
        {$MERCURE_EXTRA_DIRECTIVES}
    }

    vulcain

    {$CADDY_SERVER_EXTRA_DIRECTIVES}

    # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics
    header ?Permissions-Policy "browsing-topics=()"

    php_server
}

{$SERVER_NAME:localhost} {
	log {
		# Redact the authorization query parameter that can be set by Mercure
		format filter {
			request>uri query {
				replace authorization REDACTED
			}
		}
	}

    root /app/frontend/WebContent
    encode zstd br gzip
    file_server

    # Reverse Proxy to the Websocket
    reverse_proxy /RADDASH/* localhost:1003

    # Backend API
    handle_path /api/* {
        import symfony
    }

    # Log viewer bundle
    handle_path /log-viewer/* {
        import symfony
    }

    # assets for bundles, e.g. the log-viewer
    handle /bundles/* {
        import symfony
    }
}

worker.Caddyfile

worker {
	file ./public/index.php
	env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime
}

I’d say the first question is if this is even relevant for your use case. This is a failure of Caddy’s local CA feature trying to register its auto-generated Root CA as trusted on your system. Where the system in this case means still inside the container. That is often quite irrelevant. Where you primarily want that certificate trusted is on the client web browsers that will be connecting to that Caddy instance.

What is the local CA? Caddy by default automatically obtains or generates valid certificates for any HTTPS address you configure it to serve. When your site uses a publicly accessible domain name, such as https://example.com, Caddy will go ahead and procure a valid, widely-accepted certificate on your behalf. No further action should be necessary on the clients accessing sites like this. The local CA only kicks in when you are serving a host name Internet certificate authorities are unable to serve. These are local names, such as https://localhost https://machine.local or any plain IP addresses like https://10.10.10.10. Since you cannot obtain a certificate with built-in trust for names like that, Caddy does the next best thing, and signs them using an automatically generated local certificate authority.

These certificates, of course, will not be trusted by anyone by default. So Caddy goes one step further, and also tries to automatically register its CA as trusted. It can naturally only even attempt to do this on the local machine it’s running on. If you are a developer with a single machine, who will only ever be visiting their site on that same computer, this is sufficient. For anything beyond that, it’s barely a start. In your case the error is that it’s failing to auto-register its local CA as trusted inside the container. It’s unlikely that this makes much difference to you.

To squelch the error, you should edit the global options section in your Caddyfile, and add the setting skip_install_trust to it: Global options (Caddyfile) — Caddy Documentation

Then your use case determines if you need to do anything further. If you don’t use https, or use Internet-accessible host names, you’re already good to go. If you do use local names or IP addresses, and through https, you have to copy Caddy’s root CA to each device you’ll be accessing your site from, and add it to that system’s list of trusted root certificate authorities. The way to do so depends by OS, but you generally get on the right track by simply opening the certificate file.

The certificate file in question that you want to copy, will be, as shown in your error message, located in storage:pki/authorities/local/root.crt. The “storage:” here stands for Caddy’s storage location. On Linux it defaults to .local/share/caddy inside the home folder of the user Caddy runs as.

2 Likes

Wow, that was really a good and comprehensible Explanation! Thank You. I gues You are right, the certificate will never be needed in the production environment.

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