Can I self-host Squarespace?
YES · ONE EVENING— setup effort 2 of 4YES — it's called WordPress. It takes one prompt, a 2048 MB VPS, and about 90 minutes. That is $39 a month you stop paying Squarespace — $468 a year on the Core plan.
Why people pay for Squarespace
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Squarespace sells the parts of a website that nobody enjoys owning: templates that already look designed, hosting that never needs patching, a certificate that renews itself, a domain bought in the same checkout, and one bill for all of it. The subscription is a standing agreement that your site stays up and looks current without you thinking about it, and for most of the people paying, that agreement is the product rather than the software.
| Plan | List price | What it buys |
|---|---|---|
| Basic | $25/mo | Billed monthly. $228 a year on the annual plan, which the page shows as $19/month. Carries a 2% commerce transaction fee and a 7% digital product fee. |
| Corethe plan this page prices against | $39/mo | Billed monthly. $348 a year on the annual plan, shown as $29/month. No commerce transaction fee, 5% on digital products, unlimited contributors, and the tier where custom code is unlocked. |
| Plus | $65/mo | Billed monthly. $588 a year on the annual plan, shown as $49/month. 1% digital product fee and a lower payment processing rate. |
| Advanced | $139/mo | Billed monthly. $1,188 a year on the annual plan, shown as $99/month. No digital product fee and the lowest processing rate. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: high
Replaced by WordPress
One project, named before the prompt, so you know what you are about to install.
The CMS most of the web already runs, on a box you rent, with the themes, plugins and media library intact.
The general answer, because Squarespace sells general websites: pages, a blog, forms, galleries, a shop, and a theme somebody else designed. WordPress is the only thing on this list where all of that already exists as software you install rather than software you write, and the migration path is real, since every Squarespace export is a WordPress export file. What you take on in exchange is the update discipline: core patches itself, plugins do not, and the weekly pass over the Updates screen is the maintenance Squarespace was quietly doing for you.
What else we looked at
Ranked, with the reason each one placed where it did. Only the pick has a prompt on this page — the runners-up are named so the choice is visible, not hidden.
- WordPressour pickONE EVENING— setup effort 2 of 4
The CMS most of the web already runs, on a box you rent, with the themes, plugins and media library intact.
The general answer, because Squarespace sells general websites: pages, a blog, forms, galleries, a shop, and a theme somebody else designed. WordPress is the only thing on this list where all of that already exists as software you install rather than software you write, and the migration path is real, since every Squarespace export is a WordPress export file. What you take on in exchange is the update discipline: core patches itself, plugins do not, and the weekly pass over the Updates screen is the maintenance Squarespace was quietly doing for you.
- GhostONE EVENING— setup effort 2 of 4
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 swap
You'd run
WordPress
ONE EVENING · ~90 min to running · 2048 MB RAM
Squarespace Core · vendor list price · checked 2026-08-06 · source
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~90 min1–3 hours, through the first backup
The prompt
Two paths to the same WordPress: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
333 lines · 14,977 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 › 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.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
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 › 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.324 lines · 14,968 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
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 › 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.compose.local.ymlthe services, pinned · local layout73 lines
# 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:agent-readable mirror: /self-host/squarespace.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned69 lines
# 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_healthyCaddyfilethe hostname and TLS33 lines
# 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.shthe same install, no agent167 lines
#!/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 › 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.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Squarespace.
- Updating is the job, weekly, forever. Core installs its own security releases in the background, but plugins and themes are where most WordPress compromises start and nothing here updates them for you. The honest budget is a pass over the Updates screen every week, with a database dump taken first, and that discipline is a large part of what the hosted platform was selling.
- Every plugin is somebody else's PHP running with your site's full privileges. That ecosystem is why WordPress can do anything Squarespace does and more; it is also the entire attack surface, and quality across it ranges from excellent to abandoned in 2019.
- You own a MySQL. It has to be dumped rather than copied, the dump has to leave the box on a schedule you set, and a dump you have never restored is not a backup.
- Mail is yours now, and this install configures none. The site publishes and serves fine without it, but password resets, comment notifications and contact-form mail stay silent until you sign up with an SMTP provider and add its DNS records.
- No designer, no support desk, no CDN, no uptime commitment. Squarespace's templates and the people behind the chat window are what you stop paying for, and your site is exactly as fast and as available as one VPS on one provider.
Where this came from
“The default configuration for this image matches the official WordPress defaults in which automatic updates are enabled (so the initial install comes from the image, but after that it becomes self-managing within the /var/www/html data volume).”
- The official image documents the WORDPRESS_DB_* variables this install uses, and states that the eight authentication keys and salts default to unique random SHA1s when other environment configuration is supplied. source
- Upstream states the requirements as PHP 8.3 or greater, MariaDB 10.11 or MySQL 8.0 or greater, and HTTPS required for every install. source
- WordPress backports security fixes to older versions so that sites receive critical fixes through automatic updates, which is why core keeps moving after you pin an image digest. source
- Until the browser wizard runs, wp-admin/install.php serves the setup form; afterwards it answers with the heading Already Installed, which is what makes a finished setup checkable from a shell. source
- WP-Cron keeps no timer of its own and runs scheduled work on page loads, so a site nobody visits publishes its scheduled posts late. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Squarespace?
Not Squarespace itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is WordPress. The CMS most of the web already runs, on a box you rent, with the themes, plugins and media library intact. The install is one evening: 2 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 90 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Squarespace?
WordPress. The CMS most of the web already runs, on a box you rent, with the themes, plugins and media library intact. The general answer, because Squarespace sells general websites: pages, a blog, forms, galleries, a shop, and a theme somebody else designed. WordPress is the only thing on this list where all of that already exists as software you install rather than software you write, and the migration path is real, since every Squarespace export is a WordPress export file. What you take on in exchange is the update discipline: core patches itself, plugins do not, and the weekly pass over the Updates screen is the maintenance Squarespace was quietly doing for you. Also evaluated and ranked below it: Ghost. The prompt on this page installs WordPress only. WordPress is GPL-2.0-or-later-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Squarespace?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. WordPress itself is free and GPL-2.0-or-later-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Squarespace Core, $39/mo — $468 a year.
How hard is it really?
ONE EVENING — 1–3 hours. The rule that produced that verdict: up to three containers and at most one outside integration. You will type more than one command and read a page of documentation, and it will be running before you go to bed. The tier is derived from seven countable facts about the WordPress install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run WordPress on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same WordPress on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Every address this site builds starts with http://localhost:8152, so what you get is a drafting desk on one computer rather than a page a client or a reader can open. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.