# Can I self-host Squarespace?

**YES** — it's called WordPress. ONE EVENING setup · ~1.5 hours to running · 2 GB RAM minimum · $39/mo you stop paying ($468/yr on the Core plan).

WordPress authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/squarespace/

## Install prompt (Claude Code)

````text
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.

Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.

Install WordPress 7.0.2 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer. Its
A record must already point at this server. Say why when you ask: it becomes `WP_HOME` and
`WP_SITEURL`, and every link on the finished site is built from it.

WordPress plus MySQL 8.4 needs 2048 MB of RAM available and 10 GB free on /srv. Upstream requires
PHP 8.3 or greater and MySQL 8.0 or greater, which the pinned images meet, and states that HTTPS
is required for every install. Both publish amd64 and arm64. Measure all four:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do not
install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify a name
that does not resolve.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wordpress /srv/wordpress/backups
sudo install -d -m 750 /srv/wordpress/html
sudo install -d -m 700 /srv/wordpress/mysql
ls -la /srv/wordpress
```

Assert: `ls -la` shows `backups` owned by the login user, and `html` and `mysql` owned by root.
Leave both alone. The WordPress entrypoint copies the application into an empty `html` and chowns
it to the `www-data` user Apache runs as, MySQL does the same for its data, and a directory you
chowned yourself first makes MySQL refuse to initialise.

## 3. Secrets

Two secrets: the MySQL root password and the `wordpress` database user's password. Generate both
on the server. Do not print either, do not repeat them in your summary, and do not put them in a
log line. Hex rather than base64: both travel inside connection strings.

```bash
umask 077
cat > /srv/wordpress/.env <<EOF
WORDPRESS_SITE_URL=https://<DOMAIN>
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
WORDPRESS_DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/wordpress/.env
umask 022
ls -l /srv/wordpress/.env
```

Assert: the file exists with mode `-rw-------`. Replace `<DOMAIN>` on the first line with the real
hostname before writing it. Compose reads this file only when it runs from /srv/wordpress, so
every docker command below is preceded by a `cd`. Neither value is a browser login: the account
the user writes with is created in step 7. The eight authentication keys and salts are the image's
job, not this prompt's: it writes a random value for each into wp-config.php, which step 8 backs
up.

## 4. compose.yml

PHP's default caps uploads at 2 MB, smaller than a phone photo, so the first heredoc below writes
the override the image reads from conf.d, and the second writes the compose file:

```bash
cat > /srv/wordpress/uploads.ini <<'EOF'
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
EOF
cat > /srv/wordpress/compose.yml <<'EOF'
# WordPress · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image reference .... https://hub.docker.com/_/wordpress
#   wp-config template . https://github.com/docker-library/wordpress/blob/master/wp-config-docker.php
#   mysql image ........ https://hub.docker.com/_/mysql
#
# Two services: WordPress on Apache, and the MySQL it keeps posts, pages and
# settings in. Upstream requires MySQL 8.0 or newer and PHP 8.3 or newer, so
# this runs the MySQL 8.4 long-term series and the 7.0.2-apache tag, which is
# the PHP 8.3 build.
#
# The host's Caddy terminates TLS and this container speaks plain http on 80,
# which its wp-config works out from X-Forwarded-Proto. WP_HOME and WP_SITEURL
# come from .env, read only when Compose runs from /srv/wordpress, so no request
# header can rewrite the site address. The eight authentication keys and salts
# are in neither file: the image writes a random value for each into
# wp-config.php in /srv/wordpress/html, which every backup carries.
#
# Digests read from the registries on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - /srv/wordpress/mysql:/var/lib/mysql
    healthcheck:
      # `$$` sends a literal dollar to the container instead of interpolating.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      retries: 30
      start_period: 60s
    # No `ports:`: 3306 is reachable only from the other container.

  wordpress:
    image: wordpress:7.0.2-apache@sha256:b2d7e3153c8a96f90305a3102fb6439335237fb1a9655b617d15c5168ce2f7a3
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
      WORDPRESS_SITE_URL: ${WORDPRESS_SITE_URL}
      # Evaluated inside wp-config.php. The first two make the site address a
      # file rather than a database row; the third removes the admin editor.
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME', getenv('WORDPRESS_SITE_URL'));
        define('WP_SITEURL', getenv('WORDPRESS_SITE_URL'));
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      # Core, themes, plugins, uploads and wp-config.php. Posts are in MySQL.
      - /srv/wordpress/html:/var/www/html
      # PHP's default caps uploads at 2 MB; conf.d is the documented override.
      - /srv/wordpress/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8152.
      - "127.0.0.1:8152:80"
    depends_on:
      mysql:
        condition: service_healthy
EOF
cd /srv/wordpress && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. A warning that `MYSQL_ROOT_PASSWORD` is not set means the `cd`
did not happen and Compose never found .env; run it again from /srv/wordpress.

## 5. Caddy and TLS

Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wordpress
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# WordPress · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://developer.wordpress.org/advanced-administration/security/https/,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also WORDPRESS_SITE_URL in .env, which becomes WP_HOME and WP_SITEURL, so the
# two must always agree.

<DOMAIN> {
	# HTML, JSON and theme assets all compress well.
	encode zstd gzip

	# WordPress speaks plain http here and has to be told the visitor did not.
	# reverse_proxy sets X-Forwarded-Proto itself, ignoring what the client
	# sent, and the image's wp-config reads that header.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# No frame-blocking header: the editor and the theme customiser render the
	# site in same-origin iframes, and WordPress sends SAMEORIGIN itself.

	# 8152 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8152
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-wordpress, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it alone.

## 6. Firewall

Two ports open, both Caddy's, idempotent on a box Prompt Zero configured:

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp is
HTTP/3. 8152 is bound to 127.0.0.1 and 3306 is never published, so neither has a host port to
firewall. Assert: `ufw status verbose` prints `Status: active`, those three rules, and nothing for
8152 or 3306.

## 7. Start and verify

The first start is slow and step 10 says why, so give the loop below its full run.

```bash
cd /srv/wordpress
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/wp-admin/install.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/wp-admin/install.php | grep -c 'WordPress &rsaquo; Installation'
docker compose exec -T wordpress php -r 'echo ini_get("upload_max_filesize"), "\n";'
```

Assert all three, printing what you received. The loop ends on `200`. The second prints `1`, the
page title WordPress serves while no site exists. The third prints `64M`, step 4's override. If
any misses, stop, run `docker compose logs --tail 40 wordpress` and
`docker compose logs --tail 20 mysql`, and name the likely cause: a MySQL container that never
reports healthy is step 2, a lasting `502` is step 5, a certificate error is step 1's A record.
A running container is not success.

Until the wizard is finished, whoever loads that page becomes the administrator of this site.

STOP: tell the user to open https://<DOMAIN>/wp-admin/install.php and finish the install, and
wait. Do not continue until they confirm. WordPress asks for a language, then shows a form with
`Site Title`, `Username`, `Password`, `Your Email` and a button reading `Install WordPress`. Tell
them to pick a username other than `admin` and to put the password in their password manager
before submitting; the email address is a login and nothing else, because no mail is configured.

Once they confirm, prove the door is shut:

```bash
curl -sS https://<DOMAIN>/wp-admin/install.php | grep -c 'Already Installed'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

Assert: the first prints `1`, the heading WordPress serves once the database holds a site, and the
second `200`. Both must pass before you report success.

## 8. First backup and restore

Two artifacts. MySQL holds the posts, pages, users, comments and settings. The site archive holds
themes, plugins, uploads, wp-config.php with its eight keys, and the files that rebuild the
service.

```bash
cd /srv/wordpress
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers wordpress' | gzip > /srv/wordpress/backups/wordpress-db-$(date +%F).sql.gz
sudo tar -C /srv/wordpress -czf /srv/wordpress/backups/wordpress-site-$(date +%F).tar.gz html compose.yml uploads.ini .env -C /etc/caddy Caddyfile
ls -lh /srv/wordpress/backups/
```

Assert: both exist and both are non-empty. Print both sizes; the archive runs to tens of megabytes
because WordPress core is in it. The password expands in a shell inside the container, so it never
reaches this machine's history, and mysqldump's warning line about it is expected.
`--single-transaction` snapshots a running InnoDB database, so nothing goes offline.

A backup on the same disk is not a backup, so run this from the user's machine:

```bash
mkdir -p ~/backups/wordpress
scp vps:/srv/wordpress/backups/* ~/backups/wordpress/
```

To restore: `docker compose down`, `sudo rm -rf /srv/wordpress/mysql /srv/wordpress/html`,
recreate both as in step 2, untar the site archive into /srv/wordpress, `docker compose up -d
mysql`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" wordpress'`, then
`docker compose up -d`. Order matters: the archive carries .env, and MySQL reads its passwords
from it when it initialises an empty data directory.

## 9. Updating later

WordPress updates itself: minor and security releases install in the background into
/srv/wordpress/html, which is how upstream gets fixes onto older sites, so core does not wait for
a `docker compose pull`. The pinned tag governs PHP, Apache and the base system underneath, and
the major version a fresh install starts from. Releases are at
https://wordpress.org/download/releases/ and digests on https://hub.docker.com/_/wordpress. Take
both backups, then edit the image line in compose.yml to the new tag and digest:

```bash
cd /srv/wordpress
docker compose pull
docker compose up -d
docker compose logs --tail 30 wordpress
```

Plugins and themes are the other half of the job and nothing here updates them. Tell the user to
open the Updates screen weekly and to dump the database first.

## 10. What will probably go wrong

The first `docker compose up -d` looks like a broken install for a minute or two. I watched
`docker ps` report two running containers while https://<DOMAIN> answered `502 Bad Gateway`, and
had the Caddy config open hunting for a typo before it cleared on its own. Nothing was wrong.
MySQL spends its first half minute initialising an empty data directory, and the entrypoint then
unpacks WordPress into the empty html directory before Apache binds port 80. The log prints
`WordPress not found in /var/www/html - copying now...` and then `Complete! WordPress has been
successfully copied`. That is what step 7's loop waits for.

## 11. Out of scope

- Do not configure SMTP. WordPress serves the site without it, and mail is a provider choice with
  its own DNS records, made once the user has something to send.
- Do not install plugins or themes. Every plugin is somebody else's PHP running with the site's
  privileges, and which ones are worth that is the user's call.
- Do not set `DISABLE_WP_CRON` or add a system cron job. WordPress runs scheduled work on page
  loads, and moving that to the host is a change to make deliberately, later.
- Do not enable multisite with `WP_ALLOW_MULTISITE`. It rewrites how URLs and users work and
  cannot be undone.
````

## Chat fallback

````text
This path is slower: you paste every command yourself, and there is nobody watching the
output but you. If you can run Claude Code, use the other tab.

You are installing WordPress 7.0.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.

Read this before step 1. `<DOMAIN>` becomes `WP_HOME` and `WP_SITEURL`, the address every menu
link, redirect and image URL on the finished site is built from. Moving the site to another
hostname later means editing one file and reissuing a certificate, so pick the name you intend
to keep.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```

You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.

If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again, because Caddy cannot get a certificate for a name that does not
resolve and failed attempts count against a rate limit you cannot see. Under 2048 MB of RAM is
the case worth taking seriously: PHP and MySQL 8.4 both want room, and the OOM killer arrives
during your first image upload rather than now. Upstream's own requirements are PHP 8.3 or
greater and MySQL 8.0 or greater, which the pinned images below satisfy.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/wordpress /srv/wordpress/backups
sudo install -d -m 750 /srv/wordpress/html
sudo install -d -m 700 /srv/wordpress/mysql
ls -la /srv/wordpress
```

You should see: `backups` owned by you, and `html` and `mysql` owned by root.

If you do not: leave both owned by root on purpose. The WordPress entrypoint starts as root,
copies the whole application into an empty `html` and chowns it to the `www-data` user Apache
runs as; the MySQL image does the same for its data directory, and a directory you have already
chowned to yourself makes MySQL refuse to initialise.

## 3. Secrets

Two secrets: the MySQL root password and the password for the `wordpress` database user. Both
are generated here, on the server, and both go straight into a file only you can read. Hex
rather than base64, because both travel inside connection strings.

```bash
umask 077
cat > /srv/wordpress/.env <<EOF
WORDPRESS_SITE_URL=https://<DOMAIN>
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
WORDPRESS_DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/wordpress/.env
umask 022
ls -l /srv/wordpress/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first line with your real hostname before you paste.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/wordpress/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
both secrets, which is fine before the database exists and a problem afterwards: MySQL keeps
the password it was created with, so a changed value on an existing data directory shows up as
a connection error in the WordPress log rather than as anything about passwords.

Do not paste that file, either secret, or any command output containing them into this chat
window. Neither value is a login you will type into a browser: the account you write with is
created in step 7. WordPress also wants eight authentication keys and salts, and you are not
generating those: the image writes a random value for each into wp-config.php the first time it
starts, and step 8 backs that file up.

## 4. compose.yml

PHP's own default caps uploads at 2 MB, which is smaller than a photo from a phone. The first
heredoc below writes the override the image reads from conf.d, the second writes the compose
file. Paste each block whole, including its last line.

```bash
cat > /srv/wordpress/uploads.ini <<'EOF'
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
EOF
```

You should see: no output at all, which is what a successful heredoc looks like.

If you do not: `cat: /srv/wordpress/uploads.ini: Permission denied` means step 2 did not run, or
ran as a different user.

```bash
cat > /srv/wordpress/compose.yml <<'EOF'
# WordPress · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image reference .... https://hub.docker.com/_/wordpress
#   wp-config template . https://github.com/docker-library/wordpress/blob/master/wp-config-docker.php
#   mysql image ........ https://hub.docker.com/_/mysql
#
# Two services: WordPress on Apache, and the MySQL it keeps posts, pages and
# settings in. Upstream requires MySQL 8.0 or newer and PHP 8.3 or newer, so
# this runs the MySQL 8.4 long-term series and the 7.0.2-apache tag, which is
# the PHP 8.3 build.
#
# The host's Caddy terminates TLS and this container speaks plain http on 80,
# which its wp-config works out from X-Forwarded-Proto. WP_HOME and WP_SITEURL
# come from .env, read only when Compose runs from /srv/wordpress, so no request
# header can rewrite the site address. The eight authentication keys and salts
# are in neither file: the image writes a random value for each into
# wp-config.php in /srv/wordpress/html, which every backup carries.
#
# Digests read from the registries on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - /srv/wordpress/mysql:/var/lib/mysql
    healthcheck:
      # `$$` sends a literal dollar to the container instead of interpolating.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      retries: 30
      start_period: 60s
    # No `ports:`: 3306 is reachable only from the other container.

  wordpress:
    image: wordpress:7.0.2-apache@sha256:b2d7e3153c8a96f90305a3102fb6439335237fb1a9655b617d15c5168ce2f7a3
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
      WORDPRESS_SITE_URL: ${WORDPRESS_SITE_URL}
      # Evaluated inside wp-config.php. The first two make the site address a
      # file rather than a database row; the third removes the admin editor.
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME', getenv('WORDPRESS_SITE_URL'));
        define('WP_SITEURL', getenv('WORDPRESS_SITE_URL'));
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      # Core, themes, plugins, uploads and wp-config.php. Posts are in MySQL.
      - /srv/wordpress/html:/var/www/html
      # PHP's default caps uploads at 2 MB; conf.d is the documented override.
      - /srv/wordpress/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8152.
      - "127.0.0.1:8152:80"
    depends_on:
      mysql:
        condition: service_healthy
EOF
cd /srv/wordpress && docker compose config >/dev/null && echo "compose OK"
```

You should see: `compose OK` and nothing else.

If you do not: a warning that `MYSQL_ROOT_PASSWORD` is not set means the `cd` did not happen and
Compose never found .env, so run the last line again from /srv/wordpress. `services must be a
mapping` means the indentation was lost between the page and your terminal: run
`rm /srv/wordpress/compose.yml` and paste the block again in one go.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-wordpress
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# WordPress · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://developer.wordpress.org/advanced-administration/security/https/,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also WORDPRESS_SITE_URL in .env, which becomes WP_HOME and WP_SITEURL, so the
# two must always agree.

<DOMAIN> {
	# HTML, JSON and theme assets all compress well.
	encode zstd gzip

	# WordPress speaks plain http here and has to be told the visitor did not.
	# reverse_proxy sets X-Forwarded-Proto itself, ignoring what the client
	# sent, and the image's wp-config reads that header.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# No frame-blocking header: the editor and the theme customiser render the
	# site in same-origin iframes, and WordPress sends SAMEORIGIN itself.

	# 8152 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8152
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```

You should see: `Valid configuration` from validate, and no output at all from reload.

If you do not: run `sudo cp /etc/caddy/Caddyfile.before-wordpress /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates TLS and speaks plain http to the container, which is why the
site block matters beyond the certificate: its `reverse_proxy` sets X-Forwarded-Proto, and the
image's wp-config reads that header to work out that the visitor arrived over https.

## 6. Firewall

```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```

You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8152` or `3306`.

If you do not: delete anything for `8152` or `3306` with `sudo ufw delete allow 8152`. 8152 is
bound to 127.0.0.1 by the compose file and 3306 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp is there to answer the ACME challenge and
redirect to HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.

## 7. Start and verify

MySQL initialises an empty data directory, and the WordPress entrypoint then copies the whole
application into /srv/wordpress/html. Both finish long after `docker ps` shows two containers,
so the loop below runs for up to seven minutes on purpose.

```bash
cd /srv/wordpress
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/wp-admin/install.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/wp-admin/install.php | grep -c 'WordPress &rsaquo; Installation'
docker compose exec -T wordpress php -r 'echo ini_get("upload_max_filesize"), "\n";'
```

You should see, in order: the loop climbing through `502` and reaching `200`, then `1`, then
`64M`.

If you do not: a loop that never leaves `502` for seven minutes is worth investigating with
`docker compose logs --tail 40 wordpress` and `docker compose logs --tail 20 mysql`. A MySQL
container that never reports healthy is step 2 done wrong. A `1` that comes back as `0` means
something is answering at that address which is not this WordPress. `64M` printing as `2M` means
the uploads.ini from step 4 is missing, so Docker mounted a directory in its place: remove
`/srv/wordpress/uploads.ini` if it is a directory, write the file again, and
`docker compose up -d --force-recreate wordpress`.

Now open https://<DOMAIN>/wp-admin/install.php in a browser and finish the install. Until you
do, whoever loads that page becomes the administrator of this site, so do it now rather than
tomorrow. WordPress asks for a language first, then shows a form with `Site Title`, `Username`,
`Password`, `Your Email` and a button reading `Install WordPress`. Choose a username that is not
`admin`, put the password in your password manager before you submit, and know that the email
address is a login and nothing else here, because no mail is configured.

Then prove the door is shut:

```bash
curl -sS https://<DOMAIN>/wp-admin/install.php | grep -c 'Already Installed'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: `1`, the heading WordPress serves once the database holds a site, then `200`
from your new home page.

If you do not: a `0` from the first command means the wizard did not complete, so open the
address again and finish it. A running container is not success, and neither is a `200` on the
installer.

## 8. First backup and restore

Two artifacts. MySQL holds the posts, pages, users, comments and settings. The site archive
holds the themes, the plugins, the uploads, wp-config.php with its eight generated keys, and the
files that rebuild the service around them.

```bash
cd /srv/wordpress
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers wordpress' | gzip > /srv/wordpress/backups/wordpress-db-$(date +%F).sql.gz
sudo tar -C /srv/wordpress -czf /srv/wordpress/backups/wordpress-site-$(date +%F).tar.gz html compose.yml uploads.ini .env -C /etc/caddy Caddyfile
ls -lh /srv/wordpress/backups/
```

You should see: two files, the dump a few hundred kilobytes and the archive tens of megabytes,
because WordPress core is inside it. Nothing goes offline: `--single-transaction` snapshots a
running InnoDB database consistently.

If you do not: one warning line from mysqldump about passwords on the command line is expected
and harmless. A `.sql.gz` of about 20 bytes is an empty dump, which means mysqldump failed and
the shell created the file anyway, so run the dump line without `| gzip` to read the error.

A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:

```bash
mkdir -p ~/backups/wordpress
scp vps:/srv/wordpress/backups/* ~/backups/wordpress/
```

You should see: two files copied, and both listed by `ls -lh ~/backups/wordpress/`.

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty site:

```bash
cd /srv/wordpress
docker compose down
sudo rm -rf /srv/wordpress/mysql /srv/wordpress/html
sudo install -d -m 750 /srv/wordpress/html
sudo install -d -m 700 /srv/wordpress/mysql
sudo tar -C /srv/wordpress -xzf /srv/wordpress/backups/wordpress-site-$(date +%F).tar.gz html
docker compose up -d mysql
sleep 45
gunzip -c /srv/wordpress/backups/wordpress-db-$(date +%F).sql.gz | docker compose exec -T mysql sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" wordpress'
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: `200` from the last command, from a site whose database and files were both
deleted and rebuilt a minute earlier.

If you do not: `ERROR 1045 (28000): Access denied` means .env and the data directory disagree
about the password, which happens if you rewrote .env after MySQL first started. `Error
establishing a database connection` in a browser means MySQL had not finished initialising when
the load ran, so wait and repeat the `gunzip` line. These are the seven commands that are your
whole disaster plan; the tar extracts only `html` because compose.yml and .env are already in
place.

## 9. Updating later

WordPress updates itself. Minor and security releases install in the background into
/srv/wordpress/html, which is how upstream gets fixes onto older sites, so core does not wait
for a `docker compose pull`. What the pinned tag governs is PHP, Apache and the base system
underneath, and the major version a fresh install starts from. Releases are listed at
https://wordpress.org/download/releases/ and digests on https://hub.docker.com/_/wordpress. Take
both backups first, then edit the image line in /srv/wordpress/compose.yml to the new tag and
digest.

```bash
cd /srv/wordpress
docker compose pull
docker compose up -d
docker compose logs --tail 30 wordpress
```

You should see: the containers recreated, then Apache's start-up lines, and no repeating
restart.

If you do not: put the old tag and digest back and run the same three commands. Then load the
site and the admin before you call the update done. Plugins and themes are the other half of
this job and nothing here updates them: open the Updates screen in the admin every week, and
dump the database before you apply anything.

## 10. What will probably go wrong

The first `docker compose up -d` looks like a broken install for a minute or two. I watched
`docker ps` report two running containers while https://<DOMAIN> answered `502 Bad Gateway`, and
had the Caddy config open hunting for a typo before it cleared on its own. Nothing was wrong.
MySQL spends its first half minute initialising an empty data directory, and the entrypoint then
unpacks WordPress into the empty html directory before Apache binds port 80. The log prints
`WordPress not found in /var/www/html - copying now...` and then `Complete! WordPress has been
successfully copied`. That is what step 7's loop waits for.

## 11. Out of scope

- Do not configure SMTP. WordPress serves the site without it, and mail is a provider choice
  with its own DNS records, made once you have something to send.
- Do not install plugins or themes yet. Every plugin is somebody else's PHP running with the
  site's privileges, and the shorter that list stays, the less there is to keep patched.
- Do not set `DISABLE_WP_CRON` or add a system cron job. WordPress runs scheduled work on page
  loads, and moving that to the host is a change to make deliberately, later.
- Do not enable multisite with `WP_ALLOW_MULTISITE`. It rewrites how URLs and users work and
  cannot be undone from the admin.
````

## Local install prompt (your own computer, no server)

````text
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.

Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.

Install WordPress 7.0.2 and the MySQL 8 it stores posts in under ~/selfhost/wordpress,
answering at http://localhost:8152.

## 1. Preflight

Say this before step 2 runs, because it decides whether they want this install: WordPress here
answers at an address only this computer can open, so no reader, phone or client ever sees it.
What they get is the editor and the whole site on their own disk.

Detect the OS and measure the machine:

```bash
uname -s
case "$(uname -s)" in
  Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```

`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash, and on Linux
the distribution ID prints too, for step 2. WordPress plus MySQL 8.4 wants 2048 MB of RAM
available and 10 GB free on the home disk, and both images publish amd64 and arm64. On macOS
and Windows the memory figure is the host's, and Docker Desktop takes its share out of it.
Under either floor, print both numbers and stop.

## 2. Docker

Check before installing anything:

```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```

If that printed `docker OK` and a compose version, skip to step 3.

Otherwise, install Docker for the OS step 1 detected:

- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
  Homebrew, STOP: tell the user to download Docker Desktop from
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they
  confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
  terms, and wait for the whale icon to say it is running. Do not continue until they
  confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
  install fails, STOP: tell the user to download Docker Desktop from the URL above and
  install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
  ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
  prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
  terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
  repository, with its signing key saved to a file first, never piped into a shell. The
  fence is guarded, a no-op on anything but a Linux with apt:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo apt-get update
  sudo apt-get install -y ca-certificates curl
  sudo install -m 0755 -d /etc/apt/keyrings
  sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
  sudo chmod a+r /etc/apt/keyrings/docker.asc
  echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
  sudo usermod -aG docker "$USER"
fi
```

  Adding the user to the docker group is root-equivalent on this machine; say that to the
  user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
  plugin with their distribution's package manager, and to run this prompt again once
  `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.

## 3. Layout

```bash
mkdir -p ~/selfhost/wordpress/html ~/selfhost/wordpress/backups
if [ "$(uname -s)" = "Linux" ]; then sudo chown 33:33 ~/selfhost/wordpress/html; fi
ls -la ~/selfhost/wordpress
```

Assert: `ls -la` shows `html` and `backups`. On Linux `html` now belongs to uid 33, the
`www-data` the image runs as, which is what lets WordPress install its own updates later; on
macOS and Windows that line is a no-op, because Docker Desktop settles ownership itself.

## 4. Secrets

Two secrets: the MySQL root password and the `wordpress` user's database password. Generate
both here, print neither, and keep both out of your summary and any log line.

```bash
umask 077
cat > ~/selfhost/wordpress/.env <<EOF
WORDPRESS_SITE_URL=http://localhost:8152
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
WORDPRESS_DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/wordpress/.env
umask 022
ls -l ~/selfhost/wordpress/.env
```

Assert: the file exists with mode `-rw-------`; Git Bash ships openssl, so these lines run the
same everywhere. Compose reads .env only from ~/selfhost/wordpress, so every docker command
below starts with a `cd`, and neither value is a browser login: that account comes in step 7.
On Windows those mode bits are advisory, and the boundary is the user's own account.

## 5. compose.yml

The first heredoc lifts PHP's 2 MB upload cap, smaller than a phone photo, in the conf.d file
the image reads; the second writes the compose file:

```bash
cat > ~/selfhost/wordpress/uploads.ini <<'EOF'
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
EOF
cat > ~/selfhost/wordpress/compose.yml <<'EOF'
# WordPress · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image reference .... https://hub.docker.com/_/wordpress
#   wp-config template . https://github.com/docker-library/wordpress/blob/master/wp-config-docker.php
#   mysql image ........ https://hub.docker.com/_/mysql
#
# Two services on the computer you are sitting at, every path relative to
# ~/selfhost/wordpress/, which lets one file work on macOS, Linux and Windows.
# MySQL's data directory is a named volume because that image chowns it to its
# own uid, which a home-directory bind mount cannot allow on Windows; the site
# files stay a bind mount so themes, plugins and uploads show up in Finder or
# Explorer. Upstream requires MySQL 8.0 or newer and PHP 8.3 or newer, so this
# runs MySQL 8.4 and the 7.0.2-apache tag, the PHP 8.3 build.
#
# Nothing terminates TLS here, so WP_HOME and WP_SITEURL are the http address
# in .env, read when Compose runs from this folder. The eight authentication
# keys and salts are in neither file: the image writes a random value for each
# into wp-config.php in ./html, which every backup carries.
#
# Digests read from the registries on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - wordpress-mysql-data:/var/lib/mysql
    healthcheck:
      # `$$` sends a literal dollar to the container instead of interpolating.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      retries: 30
      start_period: 60s
    # No `ports:`: 3306 is reachable only from the other container.

  wordpress:
    image: wordpress:7.0.2-apache@sha256:b2d7e3153c8a96f90305a3102fb6439335237fb1a9655b617d15c5168ce2f7a3
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
      WORDPRESS_SITE_URL: ${WORDPRESS_SITE_URL}
      # Evaluated inside wp-config.php. The first two make the site address a
      # file rather than a database row; the third removes the admin editor.
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME', getenv('WORDPRESS_SITE_URL'));
        define('WP_SITEURL', getenv('WORDPRESS_SITE_URL'));
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      # Core, themes, plugins, uploads and wp-config.php. Posts are in MySQL.
      - ./html:/var/www/html
      # PHP's default caps uploads at 2 MB; conf.d is the documented override.
      - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8152.
      - "127.0.0.1:8152:80"
    depends_on:
      mysql:
        condition: service_healthy

volumes:
  wordpress-mysql-data:
EOF
cd ~/selfhost/wordpress && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. A warning that `MYSQL_ROOT_PASSWORD` is not set means the
`cd` did not happen.

## 6. Nothing is public

Everything binds to loopback. There is no domain to resolve and no certificate, because there
is nothing to certify; browsers treat http://localhost as a secure context, so the editor works
normally. There is no firewall rule because nothing is published beyond this machine: 8152
answers on 127.0.0.1 and nowhere else, not on the user's phone, not on a laptop on the same
wifi, not on the internet. That is the point of this path, not a defect. Confirm it:

```bash
grep -n '"127.0.0.1:' ~/selfhost/wordpress/compose.yml
```

Assert: exactly one line, `- "127.0.0.1:8152:80"`. The pattern carries the quote and colon so
the MySQL healthcheck's own 127.0.0.1 does not count; MySQL publishes no host port.

## 7. Start and verify

The first start is slow; step 10 says why.

```bash
cd ~/selfhost/wordpress
docker compose pull
docker compose up -d
for i in $(seq 1 40); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8152/wp-admin/install.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8152/wp-admin/install.php | grep -c 'WordPress &rsaquo; Installation'
```

Assert both, printing what you received: `200`, then `1`, the title served while no site
exists. If either misses, stop, run `docker compose logs --tail 40 wordpress` and
`docker compose logs --tail 20 mysql`, and name the likely cause: a MySQL that never reports
healthy is step 4, where an empty `MYSQL_ROOT_PASSWORD` stops it starting; a WordPress still
copying files wants more time. A running container is not success.

STOP: tell the user to open http://localhost:8152/wp-admin/install.php and finish the install,
and wait. Do not continue until they confirm. WordPress asks for a language, then a form with
`Site Title`, `Username`, `Password`, `Your Email` and a button reading `Install WordPress`.
The password goes in their password manager first, and the email address is only a login,
because no mail is configured.

Once they confirm, prove the door is shut:

```bash
curl -sS http://localhost:8152/wp-admin/install.php | grep -c 'Already Installed'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8152/
```

Assert: `1`, the heading WordPress serves once the database holds a site, then `200`. Both must
pass before you report success.

## 8. First backup and restore

A dump with the posts, pages, users and settings, and an archive with the themes, plugins,
uploads, wp-config.php and the files that rebuild the service.

```bash
cd ~/selfhost/wordpress
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers wordpress' | gzip > ~/selfhost/wordpress/backups/wordpress-db-$(date +%F).sql.gz
sudo tar -C ~/selfhost/wordpress -czf ~/selfhost/wordpress/backups/wordpress-site-$(date +%F).tar.gz html compose.yml uploads.ini .env
ls -lh ~/selfhost/wordpress/backups/
```

Assert: both exist, both non-empty, both sizes printed; the archive is tens of megabytes,
because WordPress core is in it. `sudo` is for Linux, where the files inside `html` belong to
uid 33. The password expands inside the container, so it never reaches this shell's history.

Both archives sit on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a sync
folder or a USB stick, and copy both there with `cp`. Assert: they confirm both arrived.

To restore: untar the archive into ~/selfhost/wordpress first, so .env is back before any
container starts, because MySQL reads its passwords from it when it initialises an empty
volume. Then `docker compose down -v`, the one place `-v` belongs, `docker compose up -d
mysql`, wait for healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T mysql sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" wordpress'`,
then `docker compose up -d` and load http://localhost:8152/.

## 9. Updating later

WordPress updates itself: minor and security releases install in the background into
~/selfhost/wordpress/html, so core does not wait for a `docker compose pull`. The pinned tag
governs PHP, Apache and the system under it.
Releases are at https://wordpress.org/download/releases/ and digests on
https://hub.docker.com/_/wordpress. Back up first, then edit the image line to the new tag and
digest:

```bash
cd ~/selfhost/wordpress
docker compose pull
docker compose up -d
docker compose logs --tail 30 wordpress
```

Plugins and themes update separately, and nothing here does it for them: tell the user to open
the Updates screen weekly, after the dump.

## 10. What will probably go wrong

Something scheduled will not happen when you expect it. I set a post to publish at 06:00, left
the laptop closed, opened the site after lunch and found it had gone live at 12:04, the minute
I loaded a page. WordPress keeps no timer of its own: it runs scheduled work during somebody's
page load, and here the only somebody is you. Nothing is broken; a site nobody visits is one
where scheduled posts and update checks happen the next time you look.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not install plugins or themes for the user. Every plugin is somebody else's PHP running
  with the site's privileges, and that choice is theirs.
- Do not set `DISABLE_WP_CRON`, add a cron job as a fix for step 10, or configure SMTP: nothing
  here sends mail.
````

## docker-compose.yml

```yaml
# WordPress · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image reference .... https://hub.docker.com/_/wordpress
#   wp-config template . https://github.com/docker-library/wordpress/blob/master/wp-config-docker.php
#   mysql image ........ https://hub.docker.com/_/mysql
#
# Two services: WordPress on Apache, and the MySQL it keeps posts, pages and
# settings in. Upstream requires MySQL 8.0 or newer and PHP 8.3 or newer, so
# this runs the MySQL 8.4 long-term series and the 7.0.2-apache tag, which is
# the PHP 8.3 build.
#
# The host's Caddy terminates TLS and this container speaks plain http on 80,
# which its wp-config works out from X-Forwarded-Proto. WP_HOME and WP_SITEURL
# come from .env, read only when Compose runs from /srv/wordpress, so no request
# header can rewrite the site address. The eight authentication keys and salts
# are in neither file: the image writes a random value for each into
# wp-config.php in /srv/wordpress/html, which every backup carries.
#
# Digests read from the registries on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - /srv/wordpress/mysql:/var/lib/mysql
    healthcheck:
      # `$$` sends a literal dollar to the container instead of interpolating.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      retries: 30
      start_period: 60s
    # No `ports:`: 3306 is reachable only from the other container.

  wordpress:
    image: wordpress:7.0.2-apache@sha256:b2d7e3153c8a96f90305a3102fb6439335237fb1a9655b617d15c5168ce2f7a3
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
      WORDPRESS_SITE_URL: ${WORDPRESS_SITE_URL}
      # Evaluated inside wp-config.php. The first two make the site address a
      # file rather than a database row; the third removes the admin editor.
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME', getenv('WORDPRESS_SITE_URL'));
        define('WP_SITEURL', getenv('WORDPRESS_SITE_URL'));
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      # Core, themes, plugins, uploads and wp-config.php. Posts are in MySQL.
      - /srv/wordpress/html:/var/www/html
      # PHP's default caps uploads at 2 MB; conf.d is the documented override.
      - /srv/wordpress/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8152.
      - "127.0.0.1:8152:80"
    depends_on:
      mysql:
        condition: service_healthy
```

## compose.local.yml

```yaml
# WordPress · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image reference .... https://hub.docker.com/_/wordpress
#   wp-config template . https://github.com/docker-library/wordpress/blob/master/wp-config-docker.php
#   mysql image ........ https://hub.docker.com/_/mysql
#
# Two services on the computer you are sitting at, every path relative to
# ~/selfhost/wordpress/, which lets one file work on macOS, Linux and Windows.
# MySQL's data directory is a named volume because that image chowns it to its
# own uid, which a home-directory bind mount cannot allow on Windows; the site
# files stay a bind mount so themes, plugins and uploads show up in Finder or
# Explorer. Upstream requires MySQL 8.0 or newer and PHP 8.3 or newer, so this
# runs MySQL 8.4 and the 7.0.2-apache tag, the PHP 8.3 build.
#
# Nothing terminates TLS here, so WP_HOME and WP_SITEURL are the http address
# in .env, read when Compose runs from this folder. The eight authentication
# keys and salts are in neither file: the image writes a random value for each
# into wp-config.php in ./html, which every backup carries.
#
# Digests read from the registries on 2026-08-06; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  mysql:
    image: mysql:8.4.11@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
    container_name: wordpress-db
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - wordpress-mysql-data:/var/lib/mysql
    healthcheck:
      # `$$` sends a literal dollar to the container instead of interpolating.
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD --silent"]
      interval: 10s
      retries: 30
      start_period: 60s
    # No `ports:`: 3306 is reachable only from the other container.

  wordpress:
    image: wordpress:7.0.2-apache@sha256:b2d7e3153c8a96f90305a3102fb6439335237fb1a9655b617d15c5168ce2f7a3
    container_name: wordpress
    restart: unless-stopped
    environment:
      WORDPRESS_DB_HOST: mysql
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: ${WORDPRESS_DB_PASSWORD}
      WORDPRESS_SITE_URL: ${WORDPRESS_SITE_URL}
      # Evaluated inside wp-config.php. The first two make the site address a
      # file rather than a database row; the third removes the admin editor.
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME', getenv('WORDPRESS_SITE_URL'));
        define('WP_SITEURL', getenv('WORDPRESS_SITE_URL'));
        define('DISALLOW_FILE_EDIT', true);
    volumes:
      # Core, themes, plugins, uploads and wp-config.php. Posts are in MySQL.
      - ./html:/var/www/html
      # PHP's default caps uploads at 2 MB; conf.d is the documented override.
      - ./uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    ports:
      # Loopback only: no other device on the wifi can reach 8152.
      - "127.0.0.1:8152:80"
    depends_on:
      mysql:
        condition: service_healthy

volumes:
  wordpress-mysql-data:
```

## Caddyfile

```text
# WordPress · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://developer.wordpress.org/advanced-administration/security/https/,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also WORDPRESS_SITE_URL in .env, which becomes WP_HOME and WP_SITEURL, so the
# two must always agree.

<DOMAIN> {
	# HTML, JSON and theme assets all compress well.
	encode zstd gzip

	# WordPress speaks plain http here and has to be told the visitor did not.
	# reverse_proxy sets X-Forwarded-Proto itself, ignoring what the client
	# sent, and the image's wp-config reads that header.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# No frame-blocking header: the editor and the theme customiser render the
	# site in same-origin iframes, and WordPress sends SAMEORIGIN itself.

	# 8152 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8152
}
```

## install.sh

```bash
#!/usr/bin/env bash
# WordPress · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
#   DOMAIN_HOST=blog.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://hub.docker.com/_/wordpress
#   https://wordpress.org/about/requirements/
#   https://developer.wordpress.org/advanced-administration/wordpress/wp-config/
#   https://developer.wordpress.org/advanced-administration/security/https/
#   https://hub.docker.com/_/mysql
#
# Two secrets are generated here, on this machine: the MySQL root password and
# the wordpress database user's password. Both go into /srv/wordpress/.env with
# mode 600 and neither is ever printed. The eight WordPress authentication keys
# and salts are not generated here: the image writes a random value for each
# into wp-config.php the first time it starts.
#
# This script cannot finish the install. WordPress creates its first account in
# a browser, and until somebody does that, whoever loads the address owns the
# site. The closing summary says so; do it in the same sitting.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/wordpress}"
DOMAIN_HOST="${DOMAIN_HOST:-}"

die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }

# --- 1. Refuse to start on a machine that is not ready -----------------------

[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. blog.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP plus MySQL 8.4 wants 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"

resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."

# --- 2. Lay the files out ----------------------------------------------------
#
# html and mysql stay root-owned: the WordPress entrypoint chowns html to the
# www-data it runs as, and the MySQL image does the same for its data directory.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/html"
sudo install -d -m 700 "$APP_DIR/mysql"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"

# PHP's own default caps uploads at 2 MB, which is smaller than a photo from a
# phone. conf.d is where the image documents changing PHP's limits.
cat > "$APP_DIR/uploads.ini" <<'INIFILE'
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
INIFILE
chmod 644 "$APP_DIR/uploads.ini"

# --- 3. Generate the two secrets, on the server ------------------------------
#
# Hex rather than base64: both travel inside connection strings. Read them later
# with
#   sudo grep -E 'MYSQL_ROOT_PASSWORD|WORDPRESS_DB_PASSWORD' /srv/wordpress/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		WORDPRESS_SITE_URL=https://${DOMAIN_HOST}
		MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
		WORDPRESS_DB_PASSWORD=$(openssl rand -hex 32)
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

cd "$APP_DIR"
docker compose config >/dev/null

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-wordpress"
	printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
	sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy

# --- 5. Ports: two open, and neither 8152 nor 3306 is one of them ------------

if command -v ufw >/dev/null 2>&1; then
	echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8152 and 3306 stay closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# MySQL initialises an empty data directory, then the WordPress entrypoint
# copies the whole application into html. A 502 during that window is expected.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/wp-admin/install.php"
for _ in $(seq 1 40); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/wp-admin/install.php" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the installer answered ${code:-nothing}. Check: docker compose logs --tail 40 wordpress"

# The page title WordPress serves while the database holds no site yet.
curl -sS "https://${DOMAIN_HOST}/wp-admin/install.php" | grep -q 'WordPress &rsaquo; Installation' \
	|| die "that address answered 200 but is not the installer. A site may already exist. Stop and investigate."

# Proof that the conf.d file above is mounted rather than quietly missing.
limit="$(docker compose exec -T wordpress php -r 'echo ini_get("upload_max_filesize");' || true)"
[ "$limit" = "64M" ] || die "PHP reports upload_max_filesize=${limit:-nothing}, not 64M. Check the uploads.ini mount."

# --- 7. The first backup, before day one ends --------------------------------

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T mysql sh -c 'exec mysqldump -u root -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers wordpress' \
	| gzip > "$APP_DIR/backups/wordpress-db-${STAMP}.sql.gz"
sudo tar -C "$APP_DIR" -czf "$APP_DIR/backups/wordpress-site-${STAMP}.tar.gz" html compose.yml uploads.ini .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/wordpress-db-${STAMP}.sql.gz" ] || die "the database dump is empty"

cat <<-DONE

	WordPress is serving its installer at https://${DOMAIN_HOST}/wp-admin/install.php
	and has no site yet.

	  1. Open that address now. WordPress asks for a language, then for a site
	     title, a username, a password and an email address. Until you fill it
	     in, anyone who loads that page becomes the administrator of this site.
	     Pick a username that is not admin. Then confirm the door is shut:
	       curl -sS https://${DOMAIN_HOST}/wp-admin/install.php | grep -c 'Already Installed'
	     It must print 1.
	  2. The email address there is only a login. No mail is configured, so
	     password resets and comment notifications stay silent until you set up
	     an SMTP provider yourself.
	  3. Two passwords are in $APP_DIR/.env, mode 600, and neither was printed
	     here. Read them with
	       sudo grep -E 'MYSQL_ROOT_PASSWORD|WORDPRESS_DB_PASSWORD' $APP_DIR/.env
	     The eight WordPress keys and salts live in html/wp-config.php, which
	     the image generated and the archive below carries.
	  4. First backup written to $APP_DIR/backups: a database dump and a site
	     archive. They are on the same disk as the data, which is not a backup.
	     Copy them somewhere else tonight.

DONE
```

## Also evaluated

Ranked below WordPress for this swap. The prompts above install WordPress only.

- **Ghost** — The same publishing platform Ghost(Pro) hosts, on your own box, with the editor, the themes and the members list intact. The better answer if what you actually have on Squarespace is a blog or a newsletter with a few pages around it. Ghost's editor is calmer, its themes are cleaner, members and paid subscriptions are built in rather than bolted on, and there is no plugin ecosystem to keep patched. It is the wrong answer for the shop, the booking form, the client portal and the twelve landing pages, which is the shape most Squarespace sites end up in.

The page this mirrors: https://caniselfhostit.com/self-host/squarespace/ · How the verdict, the timings and the prices are derived: https://caniselfhostit.com/methodology/ · Source, data and corrections: https://github.com/caniselfhostit/caniselfhostit
