Rehome the caddy data and config directory in docker

1. The problem I’m having:

I’m using caddy in a custom docker container and docker-compose.

I want the data and config directories to be stored in a single docker volume.

This means that I want:

data: /caddy/data
config: /caddy/config

whereas currently caddy uses /data/caddy and /config/caddy.

My problem is that I’m not able to find clear documentation on how to set these paths - I’ve found something like four different variable names that might change these settings.

My docker file is below.

2. Error messages and/or full log output:

You can see from the log files that caddy is still looking in /data/caddy

"ca":"https://acme-staging-v02.api.letsencrypt.org/directory","error":"open /data/caddy/acme/acme-staging-v02.api.letsencrypt.org-directory/users/XXXX/bsutton.json: no such file or directory"}

3. Caddy version:

FROM caddy:2.9.1-builder AS builder

4. How I installed and ran Caddy:

docker/docker-compose

a. System environment:

docker --version
Docker version 28.0.1, build 068a01e

b. Command:

PASTE OVER THIS, BETWEEN THE ``` LINES.
Please use the preview pane to ensure it looks nice.

c. Service/unit/compose file:

docker-compose.yaml

volumes:
  filestore:
  caddy:


services:
  caddy:
    container_name: caddy
    image: onepub/onepub-caddy:${ONEPUB_VERSION}
    restart: always
    network_mode: "host"
    cap_add:
      - NET_ADMIN
    environment:
      ACME_AGREE: "true"
      ACME_URL: ${ACME_URL} # staging or production
      EMAIL: ${AUTH_PROVIDER_EMAIL_ADDRESS}
      CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
      CADDY_DATA: /caddy/data
      CADDY_CONFIG: /caddy/config

    volumes:
      # Persist certificates
      - caddy:/caddy
      - filestore:/opt/onepub/filestore

      # Logs
      - /tmp/caddy:/var/log/caddy

    logging:
      driver: "journald"

dockerfile

# we build our own caddy file as we need the cloudflare module.
FROM caddy:2.9.1-builder AS builder

# Set custom directories for data and config
ENV CADDY_DATA_DIR=/caddy/data
ENV CADDY_CONFIG_DIR=/caddy/config


# https://caddyserver.com/docs/modules/dns.providers.cloudflare
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/WeidiDeng/caddy-cloudflare-ip

FROM caddy:2.9.1

COPY --from=builder /usr/bin/caddy /usr/bin/caddy

COPY config/caddy /etc/caddy

RUN mkdir -p /caddy/config
RUN mkdir /caddy/data

CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]


d. My complete Caddy config:

{
	# Email for Let's Encrypt notifications
	email {$EMAIL}
	# ACME CA URL allows us to flip between production and staging.
	# we default to staging.
	acme_ca {$ACME_URL:"https://acme-staging-v02.api.letsencrypt.org/directory"}

	# trigger the cloudflare ip module that periodically fetchs
    # the list of valid cloud flare proxy IP addresses.
	servers {
		trusted_proxies cloudflare {
			interval 12h
			timeout 15s
		}
	}
}

# Main Domain Configuration
*.onepub.dev, onepub.dev {
	tls {
		# API Token required for Wild card certs
		dns cloudflare {$CLOUDFLARE_API_TOKEN}
	}

    header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Frame-Options "SAMEORIGIN"
		X-Content-Type-Options "nosniff"
	}

	# Remove duplicate slashes from the URI
	# (e.g., /path//to///resource) but avoids the query string
	@multiple_slashes path_regexp multipleSlashes ^(.*)//+(.*)$
	redir @multiple_slashes {scheme}://{host}{re.multipleSlashes.1}/{re.multipleSlashes.2}{query} permanent

	# Block common AI crawler User-Agents
	@block_ai header_regexp User-Agent "(?i)(GPTBot|ChatGPT|Google-Extended|Claude-Web|Anthropic|Amazonbot|FacebookBot|cohere-ai|Bytespider|YouBot)"
	respond @block_ai 403

	# Block all PHP file requests, returning no response (similar to nginx 444)
	@block_php path *.php
	abort @block_php

	# Redirect all .mp4 requests permanently (HTTP 301) to video subdomain
	@mp4_redirect path *.mp4
	redir @mp4_redirect https://video.{host}{uri} permanent

	handle /mailhog/* {
		basic_auth {
			# password is in lastpass under 'mailhog on production'
			# hash is generated by running caddy hash-password.
			{$EMAIL} 
		}
		reverse_proxy 127.0.0.1:8025
	}

	# Global error handling, applied only for API endpoints
	handle_errors {
		@api path /api/*
		handle @api {
			root * /etc/caddy/json
			rewrite 502 /500.json
			rewrite 404 /404.json
			file_server
		}
	}

	reverse_proxy 127.0.0.1:8080 {
		header_up Host {host}
		header_up X-Real-IP {remote_host}
		transport http {
			read_timeout 300s
		}

		# Required for WebSocket support
		header_up Connection {header.Connection}
		header_up Upgrade {header.Upgrade}
	}

	encode gzip
}

# for local dev environments Note the PORT for Vaadin
# this should NEVER be exposed on an external DNS, configure /etc/hosts
local.onepub.dev {
   	tls {
		# API Token required for Wild card certs
		dns cloudflare {$CLOUDFLARE_API_TOKEN}
        # We always use staging for dev so we don't hit LE rate limites.
        acme_ca "https://acme-staging-v02.api.letsencrypt.org/directory"

	}
     header {
        # disable HSTS so we can access the staging cert.
        -Strict-Transport-Security
    }
    reverse_proxy localhost:9080
}


# Combined Video Domains Configuration
# we serve video on the video subdomain, because it would violate
# cloudflare TOS to proxy video through cloudflare
video.onepub.dev, video.beta.onepub.dev {
	tls {
		# API Token required for Wild card certs
		dns cloudflare {$CLOUDFLARE_API_TOKEN}
	}

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Frame-Options "SAMEORIGIN"
		X-Content-Type-Options "nosniff"
	}

	handle /*.mp4 {
		reverse_proxy 127.0.0.1:8080 {
			header_up Host {host}
			header_up X-Real-IP {remote_host}
			header_up X-Forwarded-Proto https
			transport http {
				read_timeout 300s
			}
            # Required for WebSocket support - although I'm not certain that
            # video uses it.
			header_up Connection {header.Connection}
			header_up Upgrade {header.Upgrade}
		}
	}

	# Domain-based redirection (fallback)
	@prod host video.onepub.dev
	redir @prod https://onepub.dev{uri} permanent
	redir https://beta.onepub.dev{uri} permanent

	encode gzip
}

5. Links to relevant resources:

So I’ve tried to set the
caddy.AppDataDir
caddy.AppConfigDir

but the result is that when I run caddy environ the environment vars appear twice in the output and caddy seems to ignore the changed version.

/srv # caddy environ
caddy.HomeDir=/root
caddy.AppDataDir=/data/caddy
caddy.AppConfigDir=/config/caddy
caddy.ConfigAutosavePath=/config/caddy/autosave.json
caddy.Version=v2.9.1 h1:OEYiZ7DbCzAWVb6TNEkjRcSCRGHVoZsJinoDR/n9oaY=
runtime.GOOS=linux
runtime.GOARCH=amd64
runtime.Compiler=gc
runtime.NumCPU=16
runtime.GOMAXPROCS=16
runtime.Version=go1.23.8
os.Getwd=/srv

HOSTNAME=xxxxx
SHLVL=1
caddy.AppConfigDir=/caddy/config
HOME=/root
CADDY_VERSION=v2.9.1
ACME_AGREE=true
TERM=xterm
ACME_URL=https://acme-staging-v02.api.letsencrypt.org/directory
caddy.AppDataDir=/caddy/data
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EMAIL=xxxxx
XDG_CONFIG_HOME=/config
XDG_DATA_HOME=/data
CLOUDFLARE_API_TOKEN=ubO4yJg1DYBd5tVD4WsQE1vJ8TxBCNbin0tIUni5
PWD=/srv

"logger":"tls.issuance.acme","msg":"creating new account because no account for configured email is known to us","email":"xxxxxx","ca":"https://acme-staging-v02.api.letsencrypt.org/directory","error":"open /data/caddy/acme/acme-staging-v02.api.letsencrypt.org-directory/users/xxxxx/bsutton.json: no such file or directory"}
2025-04-09T01:40:31.176226000Z {"level":"info","ts":1744162831.1761496,"logger":"tls.issuance.acme","msg":"creating new account because no account for configured email is known to us","email":"xxxxx","ca":"https://acme-staging-v02.api.letsencrypt.org/directory","error":"open /data/caddy/acme/acme-staging-v02.api.letsencrypt.org-directory/users/xxxxxx/bsutton.json: no such file or directory"}

so I’ve now tried the xdg vars and this seems to mostly work.

XDG_CONFIG_HOME: /caddy/config
XDG_DATA_HOME: /caddy/data

This results in caddy placing file in
/caddy/config/caddy
/caddy/data/caddy

which isn’t quite what I’m looking for but probably acceptable.

What is XDG meant to mean?

XDG_CONFIG_HOME

XDG_DATA_HOME

More details about XDG Base Directory Specification

1 Like

At the end of the day, if you want, you can set both to the same directory:

XDG_CONFIG_HOME: /srv
XDG_DATA_HOME: /srv

Just make sure there’s a /srv/caddy folder and that Caddy has permission to write to it.

XDG_CONFIG_HOME is where autosave.json gets stored, and XDG_DATA_HOME is where certs, OCSP data, and similar stuff usually goes. So eventually, you’ll end up with something like this:

/srv/caddy/
├── acme
├── autosave.json
├── certificates
├── instance.uuid
├── last_clean.json
├── locks
└── ocsp

Where your Caddyfile lives depends on the CMD in the Dockerfile. By default, it looks like this:

CMD ["caddy" "run" "--config" "/etc/caddy/Caddyfile" "--adapter" "caddyfile"]
1 Like

Notice that you’re creating env variables, even though not the correct ones, in

FROM caddy:2.9.1-builder AS builder

It should be in the final FROM caddy:2.9.1.

Probably something like this, maybe:

# we build our own caddy file as we need the cloudflare module.
FROM caddy:2.9.1-builder AS builder

# https://caddyserver.com/docs/modules/dns.providers.cloudflare
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/WeidiDeng/caddy-cloudflare-ip

FROM caddy:2.9.1

# Set custom directories for data and config
ENV XDG_CONFIG_HOME /srv
ENV XDG_DATA_HOME   /srv

COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY config/caddy /etc/caddy

RUN mkdir -p /srv/caddy

CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]

Then, you just need to mount your volume into /srv/caddy.

1 Like

OK, ta,

I’ve set them to:

ENV XDG_CONFIG_HOME=/caddy
ENV XDG_DATA_HOME=/caddy

RUN mkdir -p /caddy

By the way, it’s explained here:

1 Like