- Hide menu

Blog

Creating a Bootable USB Installer for Mac OS X Lion and Mountain Lion 10.8.5

If you need to install this version of Mac OS, there’s a lot of wrong and bad information about this topic being generated by AI and Google links. The best and working method is shown in this YT. This approach creates a Thumb Drive that books into the same screen as MacOS Recovery and you select the reinstall MacOS option.

Have tested this approach for both Lion and Mountain Lion. Also, this does not work with Sierra, which has the new createinstallmedia structure:

Merging an Empty HFS Partition Into Active Data on macOS 10.8.5 Mountain Lion With CoreStorage


Merging an Empty HFS Partition Into Active Data on macOS 10.8.5 Mountain Lion With CoreStorage

Reclaiming space stuck on the wrong side of the partition table, with an undocumented diskutil command

ℹ  TL;DR: If you have a Mac disk with an empty partition sitting before a partition full of data, standard diskutil resizeVolume / mergePartitions can’t merge them — filesystem resize only grows forward into trailing free space, never backward. CoreStorage sidesteps this because it pools partitions logically instead of growing them in place. The undocumented diskutil coreStorage addDisk command works for this on 10.8.5 — but resizeVolume‘s “fill everything” shortcut (0) does not work on this OS version — you have to pass an explicit byte count. Full working sequence and output below.
Show more ▾

The Problem

The setup: a disk with two partitions —

diskutil list
2: Apple_HFS Untitled 1   90.8 GB   disk1s2
3: Apple_HFS Untitled 2   658.8 GB  disk1s3

disk1s2 (90.8GB) was empty. disk1s3 (658.8GB) held all the active data. The goal: reclaim the 90.8GB and end up with one combined ~750GB volume, without wiping or cloning the data partition.

The obvious approach — delete the empty partition, then grow the data partition into the freed space — doesn’t work here, and it’s worth understanding why, because the failure isn’t a bug, it’s how partition resizing is fundamentally designed to work.


Why You Can’t Just Delete-and-Expand

diskutil resizeVolume and diskutil mergePartitions both grow a partition by extending its trailing edge — moving the boundary that follows the partition further out on the disk, into space that comes after it. Neither can move a partition’s starting position. Doing that would mean physically relocating every block of an existing filesystem to a new starting offset on disk — not a metadata edit, but a full block-level move — and that’s simply not what these tools do.

That’s exactly the situation here: the empty space (90.8GB) sits before the data partition, not after it. Deleting the empty partition just leaves dead space that the data partition has no native way to absorb. This is a well-documented limitation reported independently by multiple users hitting the same “partitions in the wrong order” wall — see the Ask Different thread on this exact problem.

mergePartitions doesn’t get around this either: its own man page says whichever partition you list first keeps its data and gets grown — but “grown” still means grown forward. If the partition with your data is physically the second one, this hits the same wall dressed up in a different command.


Why CoreStorage Works Around It

CoreStorage is Apple’s logical volume manager (it’s what powers Fusion Drive and FileVault 2 under the hood). Instead of a single filesystem occupying one contiguous span of a disk, a CoreStorage Logical Volume Group (LVG) pools one or more Physical Volumes (PVs) — which can be any partitions, anywhere on the disk — into one logical address space. A Logical Volume (LV) inside that group is addressed logically, not by physical position. Because of that, two partitions can be pooled together into one usable volume regardless of which one is physically first on the disk.

This is why the fix here isn’t a resize at all — it’s converting the data partition into a CoreStorage volume, then adding the empty partition into the same pool.


The Commands (With Real Output)

Starting layout:

diskutil list
/dev/disk1
 1: EFI 209.7 MB disk1s1
 2: Apple_HFS Untitled 1 90.8 GB disk1s2
 3: Apple_HFS Untitled 2 658.8 GB disk1s3
Step 1

Convert the data partition to CoreStorage, in place

bash
$ diskutil coreStorage convert disk1s3
Started CoreStorage operation on disk1s3 Untitled 2
Resizing disk to fit Core Storage headers
Creating Core Storage Logical Volume Group
Attempting to unmount disk1s3
Switching disk1s3 to Core Storage
Waiting for Logical Volume to appear
Mounting Logical Volume
Core Storage LVG UUID: CE67A415-F191-46A9-9605-473504EDEF92
Core Storage PV UUID: 0FCE0B6A-44D2-460C-B2B5-7F835A7653E7
Core Storage LV UUID: 94DBC0B5-3101-45B9-9FDB-242285F3E9E2
Core Storage disk: disk2
Finished CoreStorage operation on disk1s3 Untitled 2

Data is preserved — this is a live, in-place conversion, not a wipe. disk1s3 becomes a CoreStorage Physical Volume, wrapped in a new Logical Volume Group, with the original filesystem now exposed as a Logical Volume at disk2.

Step 2

Add the empty partition into the same LVG

bash
$ diskutil coreStorage addDisk CE67A415-F191-46A9-9605-473504EDEF92 disk1s2
Started CoreStorage operation on disk1s2 Untitled 1
Unmounting disk1s2
Touching partition type on disk1s2
Adding disk1s2 to Logical Volume Group
Switching disk1s2 to Core Storage
Waiting for Logical Volume Group to come back online
Core Storage PV UUID: 8BBA11A2-93F3-4C87-93E5-CFC111E2A938
Finished CoreStorage operation on disk1s2 Untitled 1
⚠  addDisk is undocumented — it doesn’t appear in Apple’s diskutil man page and isn’t officially supported. Reports of its reliability across different 10.8.x point releases are genuinely mixed — broken on 10.8.2, reportedly fixed on 10.8.3, then reported broken again by other users afterward. Test on a disposable disk before trusting it with real data. It worked cleanly here on 10.8.5.

After this step, diskutil list confirms both partitions are now part of the same CoreStorage group:

diskutil list
/dev/disk1
 2: Apple_CoreStorage Untitled 1 90.8 GB disk1s2
 4: Apple_CoreStorage Untitled 2 658.8 GB disk1s3

And diskutil coreStorage list shows a single LVG with both PVs and ~90.6GB of free space sitting unused inside the pool:

diskutil coreStorage list
Logical Volume Group CE67A415-F191-46A9-9605-473504EDEF92
    Size:         749678182400 B (749.7 GB)
    Free Space:   90580946944 B (90.6 GB)
    +-< Physical Volume 0FCE0B6A... (disk1s3, 658.8 GB)
    +-< Physical Volume 8BBA11A2... (disk1s2, 90.8 GB)
    +-> Logical Volume 94DBC0B5... (Size: 658.5 GB)

The space is pooled, but the Logical Volume itself hasn’t grown yet — that’s the last step.

Step 3

Grow the Logical Volume to claim the pooled space

The diskutil coreStorage resizeVolume man page (at least as documented on more recent macOS versions) says you can pass 0 as a shorthand for “grow to fill all remaining space in the LVG.” This does not work on 10.8.5:

bash
$ diskutil coreStorage resizeVolume 94DBC0B5-3101-45B9-9FDB-242285F3E9E2 0
0 does not appear to be a valid disk size

The workaround is to calculate and pass an explicit byte count instead:

math
target size = current LV size + free LVG space
            = 658,493,243,392 B + 90,580,946,944 B
            = 749,074,190,336 B

(Deliberately left slightly under the LVG’s total 749,678,182,400 B to leave headroom for CoreStorage’s own metadata — don’t try to claim every last byte.)

bash
$ diskutil coreStorage resizeVolume 94DBC0B5-3101-45B9-9FDB-242285F3E9E2 749074190336b
Started CoreStorage operation
Checking file system
Checking Journaled HFS Plus volume
Checking extents overflow file
Checking catalog file
Checking multi-linked files
Checking catalog hierarchy
Checking extended attributes file
Checking volume bitmap
Checking volume information
The volume Untitled 2 appears to be OK
Growing Logical Volume
Resizing Core Storage Logical Volume structures
Resized Core Storage Logical Volume to 749074190336 bytes
Growing file system
Finished CoreStorage operation
✓  Success — the Logical Volume, and the filesystem living on it, both grew to absorb the pooled space, with the original data intact throughout.

Takeaways

  • If your empty space is physically before your data, plain partition resize (resizeVolume, mergePartitions) can’t reach it — this isn’t a bug, it’s inherent to how in-place filesystem growth works.
  • CoreStorage’s addDisk can pool partitions regardless of their physical order, because it’s a logical layer, not a physical resize. But it’s undocumented and unsupported — back up first, and ideally rehearse the whole sequence on a throwaway test disk before running it against real data.
  • resizeVolume‘s 0-for-“everything” shortcut, as documented on later macOS versions, does not exist on 10.8.5. Use currentSize + freeSpace in bytes instead, with the b suffix, and leave a small margin under the LVG total.

Full Session Log

The complete, unedited terminal transcript (including all diskutil list output at each stage) is available as a PDF here: resize-partition.pdf



Africa safari in Zimbabwe and Botswana 2026

Places stayed

Public AdGuard Home DoH on Pi protected by Cloudflare tunnel



As an alternative to protecting a public AdGuard Home DoH server with a secret token that is validated by Caddy web server, this approach uses Cloudflare’s tunnel product to do the validation and replaces Caddy. If you are in the Cloudflare environment this approach is arguably more secure and scalable. It also has the benefit of having the DoH server answering on IPv4 and IPv6 even if the DoH server only exposes a public IPv6 address.
 

DNS over HTTPS via Cloudflare Tunnel + AdGuard Home

Token-authenticated DoH on a Raspberry Pi without port forwarding

Architecture

Cloudflare Tunnel replaces Caddy as the ingress layer. The tunnel makes an outbound-only connection from the Pi to Cloudflare’s edge — no port forwarding is required. Token enforcement and path rewriting happen at the Cloudflare edge before traffic reaches the Pi.

iOS / macOS client
  https://DoHserver-hostname/secret-token/dns-query
Cloudflare edge
    WAF Custom Rule: block if URI does not contain token (returns 404)
    Transform Rule: rewrite /secret-token/dns-query → /dns-query
  /dns-query  +  CF-Connecting-IP header
Cloudflare Tunnel (outbound from Pi, no port forwarding needed)
  https://localhost:443
AdGuard Home (DoH port 443, DoT port 853, trusted_proxies reads CF-Connecting-IP)

Upstream resolvers
Show more ▾

Key Benefits over Caddy Approach

  • No port forwarding required — tunnel is outbound only
  • No Let’s Encrypt cert management — Cloudflare handles TLS
  • Token enforcement at Cloudflare edge — invalid requests never reach the Pi
  • Works on both IPv4 and IPv6 automatically via Cloudflare anycast
  • Real client IPs preserved in AGH query log via CF-Connecting-IP header
  • No Caddy process running on the Pi

Environment

  • Raspberry Pi running Raspberry Pi OS (Bookworm or Bullseye)
  • AdGuard Home already installed and running
  • A domain managed by Cloudflare (nameservers pointing to Cloudflare)
  • Cloudflare account (free plan sufficient)

Step 1

Configure AdGuard Home

Stop AGH before editing — it overwrites changes made while running:

bash
sudo systemctl stop AdGuardHome
sudo nano /opt/AdGuardHome/AdGuardHome.yaml

Ensure the following sections are set correctly. The trusted_proxies list is Cloudflare’s published IP ranges — AGH reads CF-Connecting-IP headers only from these addresses:

AdGuardHome.yaml
tls:
  enabled: true
  server_name: DoHserver-hostname
  force_https: false
  port_https: 443
  port_dns_over_tls: 853
  allow_unencrypted_doh: false

http:
  address: 0.0.0.0:80

dns:
  trusted_proxies:
    - 103.21.244.0/22
    - 103.22.200.0/22
    - 103.31.4.0/22
    - 104.16.0.0/13
    - 104.24.0.0/14
    - 108.162.192.0/18
    - 131.0.72.0/22
    - 141.101.64.0/18
    - 162.158.0.0/15
    - 172.64.0.0/13
    - 173.245.48.0/20
    - 188.114.96.0/20
    - 190.93.240.0/22
    - 197.234.240.0/22
    - 198.41.128.0/17
    - 2400:cb00::/32
    - 2606:4700::/32
    - 2803:f800::/32
    - 2405:b500::/32
    - 2405:8100::/32
    - 2a06:98c0::/29
    - 2c0f:f248::/32
bash
sudo systemctl start AdGuardHome
sudo systemctl status AdGuardHome

# Verify AGH owns 443 and 853
sudo ss -tlnp | grep -E '443|853'
⚠ Cloudflare’s IP ranges change occasionally. Check https://www.cloudflare.com/ips/ periodically for updates.

Step 2

Install cloudflared

Install via the official Cloudflare apt repository for automatic updates:

bash
# Add Cloudflare GPG key and repository
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list

# Install
sudo apt update && sudo apt install cloudflared -y

# Verify
cloudflared --version

Step 3

Authenticate and Create the Tunnel

bash
# Authenticate — opens a browser URL, log in and select your zone
cloudflared tunnel login

# Create the tunnel — note the UUID printed
cloudflared tunnel create doh-tunnel

# Confirm it exists
cloudflared tunnel list

The credentials file lands in ~/.cloudflared/<UUID>.json. Copy both files to root’s home since the service runs as root:

bash
sudo mkdir -p /root/.cloudflared
sudo cp ~/.cloudflared/cert.pem /root/.cloudflared/
sudo cp ~/.cloudflared/*.json /root/.cloudflared/

Step 4

Create the Tunnel Config File

bash
sudo mkdir -p /etc/cloudflared
sudo nano /etc/cloudflared/config.yml

Paste, replacing <TUNNEL-UUID> with your actual UUID:

config.yml
tunnel: <TUNNEL-UUID>
credentials-file: /root/.cloudflared/<TUNNEL-UUID>.json

ingress:
  - hostname: DoHserver-hostname
    service: https://localhost:443
    originRequest:
      noTLSVerify: false
      originServerName: DoHserver-hostname
  - service: http_status:404
originServerName tells cloudflared to validate AGH’s TLS certificate against the hostname rather than localhost. The catch-all http_status:404 rejects any request that doesn’t match the hostname rule.

Step 5

Route DNS and Start the Service

Delete any existing A or AAAA record for DoHserver-hostname in the Cloudflare DNS dashboard first, then:

bash
# Creates a CNAME → <UUID>.cfargotunnel.com with orange cloud proxy
cloudflared tunnel route dns doh-tunnel DoHserver-hostname

# Install and start as systemd service
sudo cloudflared --config /etc/cloudflared/config.yml service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
sudo systemctl status cloudflared

Step 6

Cloudflare WAF Custom Rule — Block Without Token

In the Cloudflare dashboard: Security → WAF → Custom Rules → Create rule

  • Rule name: Block DoH without token
  • Action: Block (returns 404 — indistinguishable from a missing page)

Rule expression (paste into the expression editor):

Cloudflare Rule Expression
(not http.request.uri.path contains "/your-secret-token/" and http.host eq "DoHserver-hostname:443")
⚠ Use http.host with the port number included — there is a bug in the Cloudflare UI that rejects http.hostname. Since http.host includes the port, the value must be DoHserver-hostname:443 not just DoHserver-hostname.

Step 7

Cloudflare Transform Rule — Strip Token from Path

In the Cloudflare dashboard: Rules → Transform Rules → URL Rewrite → Create rule

  • Rule name: Strip DoH token
  • When incoming requests match: URI Path — starts with — /your-secret-token/
  • Path rewrite type: Static
  • Rewrite to: /dns-query

This rewrites /your-secret-token/dns-query/dns-query before forwarding to AGH via the tunnel. The WAF rule runs first, so only requests containing the token reach this rewrite.

ℹ Static rewrite is sufficient here — regex_replace requires a Business plan. Since the path is always the same the static rewrite is unambiguous.

Step 8

Test End to End

bash
# Should return a valid DNS response
dnslookup google.com https://DoHserver-hostname/your-secret-token/dns-query

# Should return 404 — no token
dnslookup google.com https://DoHserver-hostname/dns-query

# Test DoT directly (bypasses tunnel entirely)
dnslookup google.com tls://DoHserver-hostname
⚠ Do not use curl with ?dns= base64 GET requests for testing — AGH does not support this format. Use dnslookup which uses POST with binary wireformat as Apple devices do.

Optional

iOS and macOS DNS Profile

Save as dns-settings.mobileconfig and install by airdropping to iOS, opening in Safari on iOS, or double-clicking on macOS. Generate a fresh UUID with uuidgen.

dns-settings.mobileconfig
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>PayloadContent</key>
    <array>
        <dict>
            <key>PayloadDisplayName</key>
            <string>DNS (DoHserver-hostname)</string>
            <key>PayloadIdentifier</key>
            <string>com.apple.dnsSettings.managed.C54E78C7-25BD-4774-8FBD-CEA1F06F60CA</string>
            <key>PayloadType</key>
            <string>com.apple.dnsSettings.managed</string>
            <key>PayloadUUID</key>
            <string>C54E78C7-25BD-4774-8FBD-CEA1F06F60CA</string>
            <key>PayloadVersion</key>
            <integer>1</integer>
            <key>DNSSettings</key>
            <dict>
                <key>DNSProtocol</key>
                <string>HTTPS</string>
                <key>ServerURL</key>
                <string>https://DoHserver-hostname/your-secret-token/dns-query</string>
            </dict>
            <key>OnDemandRules</key>
            <array>
                <dict>
                    <key>Action</key>
                    <string>Connect</string>
                </dict>
            </array>
        </dict>
    </array>
    <key>PayloadDisplayName</key>
    <string>DNS Settings</string>
    <key>PayloadIdentifier</key>
    <string>com.example.dns.profile</string>
    <key>PayloadType</key>
    <string>Configuration</string>
    <key>PayloadUUID</key>
    <string><!-- run: uuidgen --></string>
    <key>PayloadVersion</key>
    <integer>1</integer>
</dict>
</plist>
ℹ No ServerAddresses needed — the device resolves DoHserver-hostname via normal DNS before the profile activates.

Ongoing Maintenance

Tunnel service management

bash
sudo systemctl status cloudflared
sudo systemctl restart cloudflared
sudo journalctl -u cloudflared -f

Change the secret token

Two changes required — both in the Cloudflare dashboard, nothing on the Pi:

  • WAF Custom Rule — update not contains value to new token
  • Transform Rule — update starts with value to new token
  • iOS/macOS profile — update ServerURL and reinstall on devices

Update Cloudflare IP ranges in AGH

Check https://www.cloudflare.com/ips/ periodically and update trusted_proxies in AdGuardHome.yaml if ranges change. Stop AGH before editing.

Change AGH password

Generate a bcrypt hash using single quotes to prevent shell expansion of special characters:

bash
# Always use single quotes for passwords containing $, #, ! or backticks
htpasswd -bnBC 10 '' 'your-new-password' | tr -d ':\n'

# Stop AGH, edit yaml, update password field with quoted hash, restart
sudo systemctl stop AdGuardHome
sudo nano /opt/AdGuardHome/AdGuardHome.yaml
# users:
#   - name: yourusername
#     password: "$2y$10$yourhashhere"
sudo systemctl start AdGuardHome

Revert to Caddy (if needed)

Caddy is installed but disabled. To revert:

bash
sudo systemctl stop cloudflared
sudo systemctl disable cloudflared
sudo systemctl enable caddy
sudo systemctl start caddy



DNS over HTTPS (DoH) with Caddy and AdGuard Home



A guide for setting up a Caddy web server in front of an AdGuard Home server to provide authenticated DNS over HTTPS (DoH) on the public Internet. While it’s easy to connect an AdGuard Home (AGH) directly onto the Internet, its DoH server uses a typical DoH endpoint URL – “/dns-query”. This means that it’s pretty easy to guess if a bad-actor wanted to test to see if a public IP address has a DoH server. Caddy allows the use of a secret-token in the DoH URL which provides a level of limited access to the DoH server. The DoH URL becomes “https://host/dns-query/secret-token”. For example: “https://dohserver.com/dns-query/d83djvjs”. Caddy will reject the incoming DoH query if it does not match the secret-token, or strip the token and then pass the query onto AGH for resolution.

Here is the guide:

Installing and Configuring Caddy on a Raspberry Pi

DoH with Token Authentication via Caddy + AdGuard Home

Architecture

This guide sets up Caddy as a reverse proxy in front of AdGuard Home, providing DNS over HTTPS (DoH) with token-based authentication and automatic Let’s Encrypt certificate management.

Show more ▾

Environment

  • An Internet connected LAN and devices with IPv6 connectivity
  • Raspberry Pi running Raspberry Pi OS (Bookworm or Bullseye)
  • AdGuard Home already installed and running
  • A domain pointing to your Pi’s IPv6 address (AAAA record)
  • Cloudflare managing your DNS with an API token that has Zone/DNS Edit permissions

Step 1

Install Go Programming Environment

The standard Caddy apt package does not include the Cloudflare DNS plugin required to generate and update SSL certificates via DNS-01 challenge using the Cloudflare API. A custom build is required using xcaddy, which needs the Go programming environment 1.21 or later.

bash
# Remove old apt version if present
sudo apt remove -y golang-go
sudo apt autoremove -y
sudo rm -rf /usr/local/go

# Download Go 1.26.1 for arm64 (Pi 4/5)
# Check https://go.dev/dl/ for the current latest version
wget https://go.dev/dl/go1.26.1.linux-arm64.tar.gz

# Extract to /usr/local
sudo tar -C /usr/local -xzf go1.26.1.linux-arm64.tar.gz

# Add to PATH
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
echo 'export PATH=$PATH:$(go env GOPATH)/bin' >> ~/.bashrc
source ~/.bashrc

# Verify
go version
⚠ Use linux-armv6l instead of linux-arm64 for Pi 3 or earlier.

Step 2

Build Caddy using xcaddy with Cloudflare Plugin

bash
# Install xcaddy
go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest

# Build Caddy with Cloudflare DNS module
xcaddy build --with github.com/caddy-dns/cloudflare

# Move binary to system path
sudo mv caddy /usr/bin/caddy

# Verify
caddy version

Clean up build files

Go and xcaddy are only needed to build Caddy — once the binary is in place they can be removed:

bash
rm ~/go1.26.1.linux-arm64.tar.gz
sudo rm -rf /usr/local/go
rm -rf ~/go ~/.cache/go-build

Step 3

Create Caddy System User and Directories

bash
# Create caddy system user
sudo groupadd --system caddy
sudo useradd --system \
    --gid caddy \
    --create-home \
    --home-dir /var/lib/caddy \
    --shell /usr/sbin/nologin \
    --comment "Caddy web server" \
    caddy

# Create config and log directories
sudo mkdir -p /etc/caddy
sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy

Step 3 cont’d

Reconfigure AdGuard Home

AdGuard Home must move off HTTPS port 443 so Caddy can own it. Change only the HTTPS port — DoT on 853 is completely unaffected.

In the AGH Web UI

Go to Settings → Encryption Settings and change the HTTPS port from 443 to 8443. Save and let AGH restart. Note: to get to the Web UI again after AGH restarts, append :8443 to the URL.

Verify

bash
sudo ss -tlnp | grep -E '443|853'

# Expected output:
LISTEN  AdGuardHome  *:853    # DoT unchanged
LISTEN  AdGuardHome  *:8443   # HTTPS moved

Configure AGH yaml for reverse proxy

Stop AGH before editing the yaml — AGH overwrites changes made while running:

bash
sudo systemctl stop AdGuardHome
sudo nano /opt/AdGuardHome/AdGuardHome.yaml

Ensure these settings are present (add if missing, do not duplicate):

AdGuardHome.yaml
tls:
  enabled: true
  server_name: yourserver-hostname
  force_https: false
  port_https: 8443
  port_dns_over_tls: 853
  allow_unencrypted_doh: true

http:
  address: 0.0.0.0:3000

dns:
  trusted_proxies:
    - 127.0.0.1
    - ::1
bash
sudo systemctl start AdGuardHome
sudo systemctl status AdGuardHome
allow_unencrypted_doh: true is safe here because port 3000 is bound to localhost only — Caddy is the only process that can reach it.

Step 4

Create the Caddyfile

Generate a secret token. Use all or a subset of the output as your secret token:

bash
openssl rand -hex 32

Create/Edit the Caddy configuration file:

bash
sudo nano /etc/caddy/Caddyfile

Paste the following, replacing your-secret-token with the string generated above:

Caddyfile
{
	auto_https disable_redirects
}

yourserver-hostname {
	tls {
		dns cloudflare {env.CLOUDFLARE_API_TOKEN}
	}

	log {
		output discard
	}

	handle /dns-query/your-secret-token* {
		rewrite * /dns-query?{http.request.uri.query}
		reverse_proxy http://127.0.0.1:3000 {
			header_up Host yourserver-hostname
			header_up X-Real-IP {http.request.header.Cf-Connecting-Ip}
			header_up X-Forwarded-Proto https
		}
	}

	respond 404
}
auto_https disable_redirects prevents Caddy from trying to bind to port 80, which AGH already owns.
ℹ To change the token later, update both occurrences in the handle block and reload Caddy. Also update ServerURL in your iOS/macOS profile.

Step 5

Create the systemd Service

bash
sudo nano /etc/systemd/system/caddy.service

Paste:

caddy.service
[Unit]
Description=Caddy
Documentation=https://caddyserver.com/docs/
After=network.target network-online.target
Requires=network-online.target

[Service]
Type=notify
User=caddy
Group=caddy
ExecStart=/usr/bin/caddy run --environ --config /etc/caddy/Caddyfile
ExecReload=/usr/bin/caddy reload --config /etc/caddy/Caddyfile --force
TimeoutStopSec=5s
LimitNOFILE=1048576
PrivateTmp=true
ProtectSystem=full
AmbientCapabilities=CAP_NET_BIND_SERVICE
EnvironmentFile=/etc/caddy/caddy.env

[Install]
WantedBy=multi-user.target

Step 6

Set the Cloudflare API Token

Create a scoped API token in Cloudflare: My Profile → API Tokens → Create Token with Zone/Zone/Read and Zone/DNS/Edit permissions scoped to your zone.

Now insert that token into the Caddy environment file:

bash
sudo nano /etc/caddy/caddy.env

Paste:

caddy.env
CLOUDFLARE_API_TOKEN=your-cloudflare-api-token-here

Secure the file:

bash
sudo chmod 600 /etc/caddy/caddy.env

Step 7

Start and Enable Caddy

bash
sudo systemctl daemon-reload
sudo systemctl enable caddy
sudo systemctl start caddy

# Check it started cleanly
sudo systemctl status caddy

# Confirm Caddy owns port 443
sudo ss -tlnp | grep 443

Step 8

Verify Certificate Acquisition

Caddy will immediately request a Let’s Encrypt certificate via DNS-01 challenge using the Cloudflare API. The request and result is visible in the log:

bash
sudo journalctl -u caddy -f

Look for certificate obtained successfully. This usually takes 30–60 seconds. The DNS-01 challenge creates and deletes a TXT record in Cloudflare automatically — it may happen too fast to see in the Cloudflare dashboard.


Step 9

Open Port 443 on UniFi

If not already done, create a firewall rule on your router allowing inbound TCP port 443 to your Caddy+AGH server’s IPv6 address. If you want to restrict to specific source prefixes you can scope the rule accordingly.

ℹ This endpoint is IPv6 only. IPv4-only clients can also reach it by enabling the Cloudflare DNS proxy service, or by adding IPv4 port forwarding and an A record.

Step 10

Test the Endpoint

Install the dnslookup utility from Github for proper DoH testing (uses POST with binary wireformat, which is what Apple devices use):

bash
wget https://github.com/ameshkov/dnslookup/releases/download/v1.11.2/dnslookup-linux-arm64-v1.11.2.tar.gz
tar -xzf dnslookup-linux-arm64-v1.11.2.tar.gz
sudo mv dnslookup /usr/local/bin/
rm dnslookup-linux-arm64-v1.11.2.tar.gz

Test the setup end-to-end:

bash
# Should return a valid DNS response
dnslookup google.com https://yourserver-hostname/dns-query/your-secret-token

# Should fail - no token
dnslookup google.com https://yourserver-hostname/dns-query

# Test DoT directly to AGH (unaffected by all changes)
dnslookup google.com tls://yourserver-hostname

# Test GET method (optional)
GET=1 dnslookup google.com https://yourserver-hostname/dns-query/your-secret-token
⚠ Do not use curl with ?dns= base64 GET requests for testing — AGH does not support this format. dnslookup uses POST with binary wireformat which is what AGH and Apple devices use.

Optional

iOS and macOS DNS Profile

To use the DoH server on Apple devices, a mobile configuration profile is needed. Save the following as dns-settings.mobileconfig and install by airdropping to an iOS device, opening in Safari on iOS, or double-clicking on macOS. Replace addresses with current values from dig yourserver-hostname A and dig yourserver-hostname AAAA. Generate a fresh UUID with uuidgen.

dns-settings.mobileconfig
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>PayloadContent</key>
    <array>
        <dict>
            <key>PayloadDisplayName</key>
            <string>DNS (yourserver-hostname)</string>
            <key>PayloadIdentifier</key>
            <string>com.apple.dnsSettings.managed.C54E78C7-25BD-4774-8FBD-CEA1F06F60CA</string>
            <key>PayloadType</key>
            <string>com.apple.dnsSettings.managed</string>
            <key>PayloadUUID</key>
            <string>C54E78C7-25BD-4774-8FBD-CEA1F06F60CA</string>
            <key>PayloadVersion</key>
            <integer>1</integer>
            <key>DNSSettings</key>
            <dict>
                <key>DNSProtocol</key>
                <string>HTTPS</string>
                <key>ServerURL</key>
                <string>https://yourserver-hostname/dns-query/your-secret-token</string>
                <key>ServerAddresses</key>
                <array>
                    <!-- Cloudflare proxy A records (if proxy enabled) -->
                    <!-- or your real IPv6 address (if proxy disabled) -->
                    <string>your-ipv4-or-cloudflare-ip</string>
                    <string>your-ipv6-address</string>
                </array>
            </dict>
            <key>OnDemandRules</key>
            <array>
                <dict>
                    <key>Action</key>
                    <string>Connect</string>
                </dict>
            </array>
        </dict>
    </array>
    <key>PayloadDisplayName</key>
    <string>DNS Settings</string>
    <key>PayloadIdentifier</key>
    <string>com.example.dns.profile</string>
    <key>PayloadType</key>
    <string>Configuration</string>
    <key>PayloadUUID</key>
    <string><!-- run: uuidgen --></string>
    <key>PayloadVersion</key>
    <integer>1</integer>
</dict>
</plist>

Ongoing Maintenance

Caddy certificate renewal

Caddy renews Let’s Encrypt certificates automatically. No cron jobs or manual steps needed. Certificates are stored in /var/lib/caddy/.local/share/caddy/.

Reload config after changes

bash
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy

Update Caddy binary

Go only needs to be reinstalled when updating Caddy:

bash
# Reinstall Go (see Step 1), then:
xcaddy build --with github.com/caddy-dns/cloudflare
sudo systemctl stop caddy
sudo mv caddy /usr/bin/caddy
sudo systemctl start caddy
# Remove Go again to free space
sudo rm -rf /usr/local/go ~/.cache/go-build

Change the secret token

Update both occurrences in the Caddyfile handle block, reload Caddy, and update ServerURL in your iOS/macOS profile:

bash
sudo nano /etc/caddy/Caddyfile
# Change: handle /dns-query/OLD-TOKEN*
# Change: rewrite * /dns-query?... (the strip line)
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo systemctl reload caddy
# Also update ServerURL in iOS/macOS profile and reinstall it