Can I self-host Confluence?

YES · ONE EVENING— setup effort 2 of 4

YES — it's called XWiki. It takes one prompt, a 3072 MB VPS, and about 120 minutes. That is $33.50 a month you stop paying Confluence — $402 a year on the Standard plan, 5 seats assumed.

Why people pay for Confluence

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.

Confluence is where a company's decisions go to be findable later, and most of its value is the gravity around it rather than the editor: Jira issues that link both ways, a permission model that mirrors the org chart, and a search that has already indexed eight years of meeting notes nobody would migrate by hand. Teams pay Atlassian to keep all of that available, backed up and audited without anyone on staff owning it.

Confluence plans and list prices
PlanList priceWhat it buys
FreefreeFree forever for up to 10 users, with 2 GB of storage and community support.
Standardthe plan this page prices against$6.70/mo per seatPer user per month billed monthly, the rate for the first 100 users. Annual billing is sold in brackets instead: $1,000 a year for up to 15 users, $3,350 for up to 50, $6,700 for up to 100.
Premium$13.20/mo per seatPer user per month billed monthly, the rate for the first 100 users. Annual brackets are $2,000 a year for up to 15 users, $6,600 for up to 50, $13,200 for up to 100.
Enterprisequote onlyQuote only, billed annually. The page sends you to a contact form rather than a number.

Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: high

Replaced by XWiki

One project, named before the prompt, so you know what you are about to install.

A structured enterprise wiki with spaces, macros and per-page rights, on a hostname you own and a database you can dump.

The one on this list built to the same shape as Confluence rather than to a lighter one. Structured spaces, macros you can write in the page itself, a rights model that goes per space and per page, and an extension manager that installs the rest: two decades of the same idea, LGPL, with your content in a PostgreSQL you can dump. The costs are honest ones. It is Java, so it wants 3 GB rather than 1, the first boot downloads its own interface before it will show you a page, and its default look is a wiki rather than a modern document editor.

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.

  1. XWikiour pickONE EVENING— setup effort 2 of 4

    A structured enterprise wiki with spaces, macros and per-page rights, on a hostname you own and a database you can dump.

    The one on this list built to the same shape as Confluence rather than to a lighter one. Structured spaces, macros you can write in the page itself, a rights model that goes per space and per page, and an extension manager that installs the rest: two decades of the same idea, LGPL, with your content in a PostgreSQL you can dump. The costs are honest ones. It is Java, so it wants 3 GB rather than 1, the first boot downloads its own interface before it will show you a page, and its default look is a wiki rather than a modern document editor.

  2. DocmostONE EVENING— setup effort 2 of 4

    Spaces, nested pages and an editor two people can type in at once, with every word in a Postgres you can dump.

    The right answer if what your team actually used Confluence for was writing together in a nice editor. Real-time co-editing, nested pages inside spaces, an interface people will recognise from Notion, and a Postgres behind it. It is younger and narrower than XWiki: no macro language, a thinner permission model, and its Notion-style database feature is a paid commercial add-on rather than part of the free build.

  3. BookStackONE EVENING— setup effort 2 of 4

    A team wiki organised into shelves, books, chapters and pages, on a hostname you own and a database you can dump.

    The one to pick when the honest answer is that your Confluence is a pile of pages nobody can find. BookStack forces shelves, books, chapters and pages, and that structure is the whole product. It is a small PHP application beside a MariaDB and it will be running before the others have finished their first boot. What you give up is the extensibility: no macro language, no extension marketplace, and no realtime co-editing.

The swap

You're paying

Confluence

$33.50/mo · $402/yr

is replaced by

You'd run

XWiki

ONE EVENING · ~120 min to running · 3072 MB RAM

Confluence Standard · 5 seats assumed · vendor list price · checked 2026-08-07 · source

Before you start

RAM floor
3072 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
~120 min1–3 hours, through the first backup

The prompt

Two paths to the same XWiki: 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.

authored from upstream docs · not yet machine-verified · Claude Code

Where it runs

323 lines · 14,650 bytes

What this prompt will do
  1. Preflight
  2. Layout
  3. Secrets
  4. compose.yml
  5. Caddy and TLS
  6. Firewall
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on your own machine · it runs the install over ssh vps

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 XWiki 17.10.11 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 this when you ask: the first person to open
that hostname after step 7 starts the containers becomes the wiki's administrator, because
upstream lets a guest run the setup wizard while the wiki has no registered user. This is not
an install to walk away from half-finished.

XWiki is Java. The image starts Tomcat with a 1024 MB heap and runs LibreOffice in the same
container for office import and export, with PostgreSQL beside it, so this install needs
3072 MB of RAM available and 10 GB free on /srv. Both images publish amd64 and arm64. Measure
all four first:

```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 3072 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope: on a 2 GB box the failure arrives as an OOM kill mid-wizard rather than
as a message about memory. If `dig +short` prints nothing, print that and stop.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/xwiki /srv/xwiki/backups
sudo install -d -m 700 /srv/xwiki/postgres /srv/xwiki/data
ls -la /srv/xwiki
```

Assert: `ls -la` shows `backups` owned by the login user, and `postgres` and `data` both at
mode `drwx------` owned by root. Leave both alone: the PostgreSQL image chowns its own data
directory, and the XWiki container's Tomcat runs as root, so `data` fills with root-owned files
the moment step 7 runs. `data` is the permanent directory, holding the downloaded user
interface, the Solr index, the logs, and every file anybody attaches to a page.

## 3. Secrets

One secret: the PostgreSQL password. Generate it on the server, do not print it, do not repeat
it in your summary, and keep it out of every log line. Hex rather than base64, because the
value is substituted into a JDBC connection string where punctuation helps nobody.

```bash
umask 077
cat > /srv/xwiki/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/xwiki/.env
umask 022
ls -l /srv/xwiki/.env
```

Assert: the file exists with mode `-rw-------`. Docker Compose reads this same file for the
`${DB_PASSWORD}` substitution in compose.yml and hands the value to both containers. There is
no administrator password here: XWiki seeds no account, and the human creates the first one in
the browser at step 7. Do not enable `xwiki.superadminpassword`; it is commented out in the
shipped configuration, which disables that account, and that is the state to leave it in.

## 4. compose.yml

```bash
cat > /srv/xwiki/compose.yml <<'EOF'
# XWiki · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image tags .... https://github.com/docker-library/official-images/blob/master/library/xwiki
#   image sources . https://github.com/xwiki/xwiki-docker/tree/master/17/postgres-tomcat
#
# Two services: XWiki on Tomcat, and the PostgreSQL that holds every page.
# 17.10.11 is the line the official-images file above labels LTS; the 18.x tags
# are the monthly stable line. The image carries the XWiki WAR but not the
# default set of wiki pages, so the wizard downloads those on first boot into
# the permanent directory mounted below, which also holds the Solr index and
# every attachment: file storage has been XWiki's attachment default since
# 10.5. `init: true` reaps the LibreOffice children the JVM leaves as zombies,
# as upstream does. Digests read 2026-08-07; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: xwiki-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: xwiki
      POSTGRES_USER: xwiki
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      # Upstream initialises it this way; encoding is fixed at create time.
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale-provider=builtin --locale=C.UTF-8"
    volumes:
      - /srv/xwiki/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xwiki -d xwiki"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  xwiki:
    image: xwiki:17.10.11-postgres-tomcat@sha256:f1b36072ed82d2e7414b3b12b670d4ddf981f32698f70a8fa8deb606fe989621
    container_name: xwiki
    restart: unless-stopped
    init: true
    # DB_PASSWORD arrives from /srv/xwiki/.env, mode 600. The entrypoint
    # writes it and the three below into WEB-INF/hibernate.cfg.xml.
    env_file: /srv/xwiki/.env
    environment:
      DB_HOST: postgres
      DB_DATABASE: xwiki
      DB_USER: xwiki
      # No JAVA_OPTS: setenv.sh gives Tomcat a 1024 MB heap, and JAVA_OPTS
      # is where you raise it.
    volumes:
      - /srv/xwiki/data:/usr/local/xwiki
    healthcheck:
      # Tomcat boots, then XWiki builds its schema: long start period.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/bin/view/Main/ || exit 1"]
      interval: 15s
      retries: 20
      start_period: 300s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8172.
      - "127.0.0.1:8172:8080"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/xwiki && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. Do not set `CONTEXT_PATH`: left unset, the entrypoint deploys
XWiki as the Tomcat ROOT context, which makes https://<DOMAIN>/ the wiki rather than
https://<DOMAIN>/xwiki/.

## 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 takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-xwiki
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# XWiki · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/docker-compose.yml 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. XWiki builds the
# links inside a page relative to the request it answered, so it follows this
# hostname without being told it.

<DOMAIN> {
	# Pages, search results and the JavaScript bundles are all text.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8172 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. XWiki's realtime
	# editing rides a WebSocket, and reverse_proxy performs that upgrade
	# with no extra directive.
	reverse_proxy 127.0.0.1:8172
}
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-xwiki, reload, and report what it objected to. Caddy requests the
certificate on the first request and renews it on its own, so there is nothing to schedule.

## 6. Firewall

Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:

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

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8172 stays closed because compose binds it to 127.0.0.1, and 5432 because
compose publishes no host port for it. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule naming 8172 or 5432.

## 7. Start and verify

Two slow things happen in a row, and neither is a fault. Tomcat starts and XWiki builds its
whole schema, which is the loop below. Then a human runs the wizard, whose second step
downloads the default user interface from upstream's extension repository, which needs
outbound internet from this server and takes minutes.

```bash
cd /srv/xwiki
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL https://<DOMAIN>/ | grep -c 'id="distributionWizard"'
curl -sSL https://<DOMAIN>/ | grep -c 'Distribution Wizard'
```

Assert all three and print what you received for each. The loop ends printing `200`, after
two redirects: `/` goes to `/bin/view/Main/`, which goes to the wizard. Both greps print `1`.
If any of the three misses, stop, run `docker compose logs --tail 60 xwiki` and
`docker compose logs --tail 20 postgres`, and name the likely earlier step: a database that
never reports healthy points at step 2, a `502` while the XWiki log still prints schema updates
wants more time rather than a fix, and a `404` where the wizard was expected means
`CONTEXT_PATH` reached the container in step 4. A running container is not success.

The first screen at https://<DOMAIN>/ is the wizard, headed `Distribution Wizard`, and its
second step is headed `Admin user`.

STOP: tell the user to open https://<DOMAIN>/ now, work through the wizard, register the
administrator on the `Admin user` step, let the `User Interface` step finish, and wait.
Do not continue until they confirm they reached the report at the end. Tell them to put that
password in their password manager first: this install configures no mail, so there is no
reset route.

Once they confirm, prove the wizard is shut to everyone else:

```bash
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/bin/distribution/XWiki/Distribution | grep -c 'id="distributionWizard"'
```

Assert: the first prints `200` and the second prints `0`, which is the security assert here.
Upstream gives a signed-out visitor the wizard only while the main wiki has no registered user,
so once the administrator exists a stranger asking for that URL gets a login form. Both asserts
must pass before you report success.

## 8. First backup and restore

Two artifacts, and you need both. The database holds every page, revision, comment and user.
The archive holds the permanent directory, where the attachments actually are, plus the two
files that rebuild the service around them.

```bash
cd /srv/xwiki
docker compose exec -T postgres pg_dump -U xwiki -d xwiki | gzip > /srv/xwiki/backups/xwiki-db-$(date +%F).sql.gz
sudo tar -czf /srv/xwiki/backups/xwiki-files-$(date +%F).tar.gz -C /srv/xwiki compose.yml .env data -C /etc/caddy Caddyfile
ls -lh /srv/xwiki/backups/
```

Assert: both files exist and both are non-empty. Print both sizes. Nothing is stopped, because
`pg_dump` snapshots a running database consistently. The archive runs under sudo because the
container wrote that directory as root.

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

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

To restore: `docker compose down`, `sudo rm -rf /srv/xwiki/postgres /srv/xwiki/data`, recreate
both as step 2 does, untar the archive into /srv/xwiki so `.env` and the permanent directory
are back before anything starts, `docker compose up -d postgres`, wait about 30 seconds for it
to report healthy, pipe `gunzip -c` on the `.sql.gz` into
`docker compose exec -T postgres psql -U xwiki -d xwiki`, then `docker compose up -d`. Tell the
user why both matter: the dump alone restores a wiki whose every attachment is a broken link,
because XWiki has kept attachment content on disk rather than in the database since 10.5.

## 9. Updating later

New versions are listed at https://github.com/xwiki/xwiki-platform/releases, and the tag each
maps to is in https://github.com/docker-library/official-images/blob/master/library/xwiki. Stay
on 17.10.x: it is the long-term line, and 18.x is the monthly release train. Take both backup
artifacts first, then edit the image line in /srv/xwiki/compose.yml to the new tag and digest:

```bash
cd /srv/xwiki
docker compose pull
docker compose up -d
docker compose logs --tail 40 xwiki
```

XWiki migrates its own schema on the way up, and a version bump makes the wizard reappear for
its user-interface step, which is expected. Watch that log until it settles, then re-run
step 7's checks before calling the update done.

## 10. What will probably go wrong

You will think the wizard has frozen. I did. Its second step downloads the whole default user
interface from upstream's extension repository, and the browser sits on a progress bar for
several minutes while `docker compose logs -f xwiki` prints almost nothing. Before that, the
first boot spends minutes creating the schema while Caddy answers `502` to everything. Let the
loop in step 7 run all sixty attempts, and once the wizard is on screen leave it alone until it
moves by itself. If that step does fail, the cause is almost always that this server has no
outbound internet access, not XWiki.

## 11. Out of scope

- Do not configure SMTP. XWiki runs without it, and every notification and password reset it
  would have emailed stays inside the web interface.
- Do not replace /usr/local/xwiki/data/xwiki.cfg to force `xwiki.url.protocol=https`. Links
  inside a page are relative, so the wiki works over TLS as installed; that setting changes
  only the absolute URLs in feeds, and swapping the file replaces every other default in it.
- Do not install the Confluence import extension now. It is a contributed extension the user
  installs from the Extension Manager once they have a wiki to import into.
- Do not point `SOLR_BASE_URL` at an external Solr. The embedded index in the permanent
  directory is the choice here, and a second search service is a third container.
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 XWiki 17.10.11 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. From the moment step 7 starts the containers until you have created
the administrator account in the browser, anybody who opens `<DOMAIN>` can create it instead of
you: XWiki lets a signed-out visitor run its setup wizard for exactly as long as the wiki has
no registered user. Do not start step 7 unless you can finish step 7.

## 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 `3072` 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 hostname that
does not resolve and failed attempts count against a rate limit you cannot see. The memory
floor is the one not to argue with. XWiki is Java: the image starts Tomcat with a 1024 MB heap
and runs a LibreOffice process in the same container for office import and export, and
PostgreSQL sits beside both. On a 2 GB box the first failure you see is an OOM kill in the
middle of the setup wizard, and it will not mention memory.

## 2. Layout

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/xwiki /srv/xwiki/backups
sudo install -d -m 700 /srv/xwiki/postgres /srv/xwiki/data
ls -la /srv/xwiki
```

You should see: `backups` owned by you, and `postgres` and `data` both at mode `drwx------`
owned by root.

If you do not: leave those two owned by root on purpose. The PostgreSQL image chowns its own
data directory the first time it starts and refuses one somebody has already claimed, and the
XWiki container's Tomcat runs as root, so it fills `data` with root-owned files as soon as it
boots. `data` is XWiki's permanent directory: the user interface it downloads in step 7, the
search index, the logs, and every file anybody attaches to a page.

## 3. Secrets

One secret, the PostgreSQL password. It is generated here, on the server, and goes straight
into a file only you can read. Hex rather than base64, because the value ends up inside a JDBC
connection string where punctuation helps nobody.

```bash
umask 077
cat > /srv/xwiki/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/xwiki/.env
umask 022
ls -l /srv/xwiki/.env
```

You should see: mode `-rw-------`, your own username twice, and the path.

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/xwiki/.env` and carry
on. If the file already existed from an earlier attempt, this block has now overwritten the
password, which is fine before the database exists and a problem afterwards: PostgreSQL keeps
the password it was created with, so a changed `DB_PASSWORD` against an existing data directory
produces an authentication failure in the XWiki log rather than anything about passwords.

Do not paste that file, that password, or any command output containing it into this chat
window. The agent path never sees the value; this path will hand it to a third party unless you
keep it out. Read it yourself with `sudo grep DB_PASSWORD /srv/xwiki/.env` in a terminal, not
here. There is no administrator password in that file: XWiki seeds no account, and you create
the first one in a browser at step 7.

## 4. compose.yml

Paste the whole block at once, including the last two lines.

```bash
cat > /srv/xwiki/compose.yml <<'EOF'
# XWiki · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image tags .... https://github.com/docker-library/official-images/blob/master/library/xwiki
#   image sources . https://github.com/xwiki/xwiki-docker/tree/master/17/postgres-tomcat
#
# Two services: XWiki on Tomcat, and the PostgreSQL that holds every page.
# 17.10.11 is the line the official-images file above labels LTS; the 18.x tags
# are the monthly stable line. The image carries the XWiki WAR but not the
# default set of wiki pages, so the wizard downloads those on first boot into
# the permanent directory mounted below, which also holds the Solr index and
# every attachment: file storage has been XWiki's attachment default since
# 10.5. `init: true` reaps the LibreOffice children the JVM leaves as zombies,
# as upstream does. Digests read 2026-08-07; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: xwiki-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: xwiki
      POSTGRES_USER: xwiki
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      # Upstream initialises it this way; encoding is fixed at create time.
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale-provider=builtin --locale=C.UTF-8"
    volumes:
      - /srv/xwiki/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xwiki -d xwiki"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  xwiki:
    image: xwiki:17.10.11-postgres-tomcat@sha256:f1b36072ed82d2e7414b3b12b670d4ddf981f32698f70a8fa8deb606fe989621
    container_name: xwiki
    restart: unless-stopped
    init: true
    # DB_PASSWORD arrives from /srv/xwiki/.env, mode 600. The entrypoint
    # writes it and the three below into WEB-INF/hibernate.cfg.xml.
    env_file: /srv/xwiki/.env
    environment:
      DB_HOST: postgres
      DB_DATABASE: xwiki
      DB_USER: xwiki
      # No JAVA_OPTS: setenv.sh gives Tomcat a 1024 MB heap, and JAVA_OPTS
      # is where you raise it.
    volumes:
      - /srv/xwiki/data:/usr/local/xwiki
    healthcheck:
      # Tomcat boots, then XWiki builds its schema: long start period.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/bin/view/Main/ || exit 1"]
      interval: 15s
      retries: 20
      start_period: 300s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8172.
      - "127.0.0.1:8172:8080"
    depends_on:
      postgres:
        condition: service_healthy
EOF
cd /srv/xwiki && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/xwiki/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/xwiki/compose.yml` and paste again in one go. Do not add `CONTEXT_PATH` to that
environment block. Left unset, the image's entrypoint deploys XWiki as the Tomcat ROOT context,
which is what makes https://<DOMAIN>/ the wiki rather than https://<DOMAIN>/xwiki/.

## 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-xwiki
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# XWiki · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/docker-compose.yml 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. XWiki builds the
# links inside a page relative to the request it answered, so it follows this
# hostname without being told it.

<DOMAIN> {
	# Pages, search results and the JavaScript bundles are all text.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8172 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. XWiki's realtime
	# editing rides a WebSocket, and reverse_proxy performs that upgrade
	# with no extra directive.
	reverse_proxy 127.0.0.1:8172
}
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-xwiki /etc/caddy/Caddyfile`, reload,
and paste again. Caddy requests the certificate on the first request to the hostname and renews
it by itself, so there is nothing to schedule and nothing to renew by hand.

## 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 `8172` or `5432`.

If you do not: delete anything for `8172` or `5432` with `sudo ufw delete allow 8172`. 8172 is
bound to 127.0.0.1 by the compose file and 5432 is never published at all, so the database has
no host port a firewall rule could apply to. 80/tcp redirects to HTTPS and answers the ACME
challenge, 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 any further.

## 7. Start and verify

Two slow things happen in a row here, and neither is a fault. Tomcat starts and XWiki builds
its whole schema in PostgreSQL, which is what the loop waits for. Then you run the setup
wizard, whose second step downloads the default user interface from upstream's extension
repository, which needs outbound internet from this server and takes minutes.

```bash
cd /srv/xwiki
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sSL -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL https://<DOMAIN>/ | grep -c 'id="distributionWizard"'
curl -sSL https://<DOMAIN>/ | grep -c 'Distribution Wizard'
```

You should see, in order: the loop counting up and reaching `200`, then `1`, then `1`. The
`200` arrives after two redirects, because `/` sends you to `/bin/view/Main/`, which sends you
to the wizard.

If you do not: a `502` that never becomes `200` while `docker compose logs --tail 40 xwiki`
still prints schema updates wants more time rather than a fix, so let all sixty attempts run.
If the loop finishes without a `200`, run `docker compose logs --tail 20 postgres` first,
because a database that never reports healthy is step 2 done wrong, and
`docker compose logs --tail 60 xwiki` second. A `404` where the wizard was expected means
`CONTEXT_PATH` reached the container in step 4. A green `docker compose ps` on its own is not
success.

The first screen at https://<DOMAIN>/ is the wizard, headed `Distribution Wizard`. Open it in a
browser now. Its second step is headed `Admin user`: register the account you want to
administer this wiki with, put that password in your password manager as you type it, because
this install configures no mail and there is no reset route. Then let the `User Interface` step
run to the end. It downloads the whole default flavor and sits on a progress bar for minutes.

Once the wizard shows you its report, confirm it is shut to everyone else:

```bash
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sSL https://<DOMAIN>/bin/distribution/XWiki/Distribution | grep -c 'id="distributionWizard"'
```

You should see: `200`, then `0`.

If you do not: a `1` on the second line means the wizard is still open to signed-out visitors,
which means the administrator account did not get created, so go back to the browser and finish
that step before you leave this running. That check is the security assert here: XWiki offers a
signed-out visitor the wizard only while the wiki has no registered user, so a `0` is the proof
that the window closed behind you.

## 8. First backup and restore

Two artifacts, and you need both. The database holds every page, revision, comment and user.
The file archive holds the permanent directory, where the attachments actually are, plus the
two files that rebuild the service around them.

```bash
cd /srv/xwiki
docker compose exec -T postgres pg_dump -U xwiki -d xwiki | gzip > /srv/xwiki/backups/xwiki-db-$(date +%F).sql.gz
sudo tar -czf /srv/xwiki/backups/xwiki-files-$(date +%F).tar.gz -C /srv/xwiki compose.yml .env data -C /etc/caddy Caddyfile
ls -lh /srv/xwiki/backups/
```

You should see: two files, the dump a few hundred kilobytes and the archive a few hundred
megabytes on a fresh install, because the flavor and the search index are inside it. Nothing
goes offline: `pg_dump` snapshots a running database consistently.

If you do not: a `.sql.gz` of about 20 bytes is an empty dump, which means `pg_dump` failed and
the shell created the file anyway, so run the dump line without `| gzip` to read the error. The
`tar` needs `sudo` because the container wrote `data` as root.

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/xwiki
scp vps:/srv/xwiki/backups/* ~/backups/xwiki/
```

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

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 `vps` alias Prompt Zero created lives.

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

```bash
cd /srv/xwiki
docker compose down
sudo rm -rf /srv/xwiki/postgres /srv/xwiki/data
sudo install -d -m 700 /srv/xwiki/postgres /srv/xwiki/data
sudo tar -xzf /srv/xwiki/backups/xwiki-files-$(date +%F).tar.gz -C /srv/xwiki compose.yml .env data
docker compose up -d postgres
sleep 30
gunzip -c /srv/xwiki/backups/xwiki-db-$(date +%F).sql.gz | docker compose exec -T postgres psql -U xwiki -d xwiki
docker compose up -d
sleep 60
curl -sSL -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```

You should see: `CREATE TABLE` and `COPY` lines from psql, then `200` from the last command,
and your wiki back in a browser with the account you made still working.

If you do not: `role "xwiki" does not exist` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. Understand why both artifacts are
in that sequence: XWiki has kept attachment content on disk rather than in the database since
version 10.5, so a dump restored without `data` gives you every page back with every attachment
a broken link.

## 9. Updating later

New versions are listed at https://github.com/xwiki/xwiki-platform/releases, and the tag each
one maps to is in https://github.com/docker-library/official-images/blob/master/library/xwiki.
Stay on the 17.10.x line: it is the long-term one, and the 18.x tags are the monthly release
train. Take both backup artifacts first, then edit the `image:` line in /srv/xwiki/compose.yml
to the new tag and its digest.

```bash
cd /srv/xwiki
docker compose pull
docker compose up -d
docker compose logs --tail 40 xwiki
```

You should see: schema migration output, then Tomcat starting, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. A version bump
also makes the wizard reappear for its user-interface step the next time you open the wiki,
which is the upgrade working rather than failing. Re-run the checks from step 7 before you call
the update done.

## 10. What will probably go wrong

You will think the wizard has frozen. I did. Its second step downloads the whole default user
interface from upstream's extension repository, and the browser sits on a progress bar for
several minutes while `docker compose logs -f xwiki` prints almost nothing. Before that, the
first boot spends minutes creating the schema while Caddy answers `502` to everything. Let the
loop in step 7 run all sixty attempts, and once the wizard is on screen leave it alone until it
moves by itself. If that step does fail, the cause is almost always that this server has no
outbound internet access, not XWiki.

## 11. Out of scope

- Do not configure SMTP. XWiki runs without it, and every notification and password reset it
  would have emailed stays inside the web interface.
- Do not replace /usr/local/xwiki/data/xwiki.cfg to force `xwiki.url.protocol=https`. Links
  inside a page are relative, so the wiki works over TLS as installed; that setting changes
  only the absolute URLs in feeds, and swapping the file replaces every other default in it.
- Do not install the Confluence import extension now. It is a contributed extension you install
  from the Extension Manager once you have a wiki to import into.
- Do not point `SOLR_BASE_URL` at an external Solr. The embedded index in the permanent
  directory is the choice here, and a second search service is a third container.

318 lines · 14,984 bytes

What this prompt will do
  1. Preflight
  2. Docker
  3. Layout
  4. Secrets
  5. compose.yml
  6. Nothing is public
  7. Start and verify
  8. First backup and restore
  9. Updating later
  10. What will probably go wrong
  11. Out of scope

Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.

paste it into Claude Code in a terminal on this computer · installs Docker Desktop if it is missing · no server, no domain

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 XWiki 17.10.11, with the PostgreSQL it keeps every page in, under ~/selfhost/xwiki,
answering at http://localhost:8172.

## 1. Preflight

Say this to the user before step 2; it decides whether they want this install. XWiki is built
for a team, and this one answers only at http://localhost:8172, which means "this computer"
wherever it is read: a colleague sent that address gets an error, and so does the user's own
phone. What they get is a private wiki for one. Then 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; on Linux the
ID and codename print next, for step 2. XWiki is Java: the image gives Tomcat a 1024 MB heap
and runs LibreOffice in the same container, with PostgreSQL beside it, so this needs 3072 MB of
RAM available and 10 GB free on the home disk. Both images publish amd64 and arm64. On macOS
and Windows that figure is the host's, minus Docker Desktop's VM allocation, so check its
memory limit too. Under either floor, print both 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/xwiki/data ~/selfhost/xwiki/backups
ls -la ~/selfhost/xwiki
```

Assert: `data` and `backups`, owned by the user. `data` is XWiki's permanent directory: the
downloaded user interface, the search index, the logs and every attachment. The database is a
volume Docker manages, so nothing here needs an ownership fix; on Linux the container's Tomcat
writes inside `data` as root, which is expected.

## 4. Secrets

One secret: the PostgreSQL password. Generate it here, print it nowhere, and keep it out of
your summary and every log line.

```bash
umask 077
cat > ~/selfhost/xwiki/.env <<EOF
DB_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/xwiki/.env
umask 022
ls -l ~/selfhost/xwiki/.env
```

Assert: mode `-rw-------`. Git Bash ships openssl, so this runs the same everywhere. Docker
Compose reads this file for the `${DB_PASSWORD}` substitution and hands the value to both
containers. No administrator password is in it: XWiki seeds no account, and the user creates
the first one in the browser at step 7. On Windows the mode bits are advisory, and the boundary
is the user's own account.

## 5. compose.yml

```bash
cat > ~/selfhost/xwiki/compose.yml <<'EOF'
# XWiki · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image tags .... https://github.com/docker-library/official-images/blob/master/library/xwiki
#   image sources . https://github.com/xwiki/xwiki-docker/tree/master/17/postgres-tomcat
#
# Two services, every path relative to ~/selfhost/xwiki/ so one file works on
# macOS, Linux and Windows. The database is a named volume, not a bind mount,
# because PostgreSQL chowns its data directory to its own uid and a Windows
# home bind cannot allow that. The permanent directory stays a bind mount: it
# holds the flavor, the Solr index and every attachment, file storage having
# been XWiki's attachment default since 10.5. `init: true` reaps LibreOffice's
# orphans. 17.10.11 is the LTS line. Digests read 2026-08-07; both multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: xwiki-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: xwiki
      POSTGRES_USER: xwiki
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      # Upstream initialises it this way; encoding is fixed at create time.
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale-provider=builtin --locale=C.UTF-8"
    volumes:
      - xwiki-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xwiki -d xwiki"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  xwiki:
    image: xwiki:17.10.11-postgres-tomcat@sha256:f1b36072ed82d2e7414b3b12b670d4ddf981f32698f70a8fa8deb606fe989621
    container_name: xwiki
    restart: unless-stopped
    init: true
    # DB_PASSWORD arrives from ./.env, mode 600. The entrypoint writes it
    # and the three below into WEB-INF/hibernate.cfg.xml.
    env_file: ./.env
    environment:
      DB_HOST: postgres
      DB_DATABASE: xwiki
      DB_USER: xwiki
      # No JAVA_OPTS: setenv.sh gives Tomcat a 1024 MB heap, and JAVA_OPTS
      # is where you raise it.
    volumes:
      - ./data:/usr/local/xwiki
    healthcheck:
      # Tomcat boots, then XWiki builds its schema: long start period.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/bin/view/Main/ || exit 1"]
      interval: 15s
      retries: 20
      start_period: 300s
    ports:
      # Loopback only: no other device on the wifi can reach 8172.
      - "127.0.0.1:8172:8080"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  xwiki-pgdata:
EOF
cd ~/selfhost/xwiki && docker compose config >/dev/null && echo "compose OK"
```

Assert: `compose OK`. Two services, one published port, one named volume.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule: nothing to resolve, no public name to
attest, nothing published beyond loopback. Browsers treat http://localhost as a secure context,
so pages needing crypto still work. 8172 is bound to 127.0.0.1: not the user's phone, not a
laptop on the wifi, not anyone on the internet. That is the point of this path. Confirm:

```bash
grep -c '"127.0.0.1:' ~/selfhost/xwiki/compose.yml
```

Assert: `1`, the published-port line. PostgreSQL publishes no host port, so 5432 cannot appear.

## 7. Start and verify

Two slow things happen in a row, and neither is a fault. Tomcat starts and XWiki builds its
whole schema, the loop below. Then the user runs the wizard, whose second step downloads the
default user interface from upstream's extension repository: minutes, and this computer has to
be online.

```bash
cd ~/selfhost/xwiki
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sSL -o /dev/null -w '%{http_code}' http://localhost:8172/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sSL http://localhost:8172/ | grep -c 'id="distributionWizard"'
curl -sSL http://localhost:8172/ | grep -c 'Distribution Wizard'
```

Assert all three and print what you received for each. The loop ends on `200`, after two
redirects: `/` goes to `/bin/view/Main/`, which goes to the wizard. Both greps print `1`. If
any misses, stop, run `docker compose logs --tail 60 xwiki` and
`docker compose logs --tail 20 postgres`, and name the cause: a database that never reports
healthy points at step 4, where an empty `DB_PASSWORD` leaves PostgreSQL refusing to start; a
refused connection while the XWiki log still prints schema updates wants more time. If
`port is already allocated` came back, find what holds 8172 (`lsof -nP -iTCP:8172 -sTCP:LISTEN`,
or `netstat -ano | findstr :8172` on Windows) and stop until it is freed. A running container
is not success.

The first screen at http://localhost:8172/ is the wizard, headed `Distribution Wizard`; its
second step is headed `Admin user`.

STOP: tell the user to open http://localhost:8172/ now, work through the wizard, register the
administrator on the `Admin user` step, let the `User Interface` step finish, and wait.
Do not continue until they confirm they reached the report at the end. Have them put that
password in their password manager: nothing here sends reset mail.

Once they confirm, check the wizard closed behind them:

```bash
curl -sSL -o /dev/null -w '%{http_code}\n' http://localhost:8172/
curl -sSL http://localhost:8172/bin/distribution/XWiki/Distribution | grep -c 'id="distributionWizard"'
```

Assert: the first prints `200` and the second prints `0`. Upstream offers a signed-out visitor
the wizard only while the wiki has no registered user, so once the administrator exists that
URL answers with a login form. Both must pass before you report success.

## 8. First backup and restore

Two artifacts, and you need both. The database holds every page, revision, comment and user.
The archive holds the permanent directory, where the attachments are, plus the two files that
rebuild the service.

```bash
cd ~/selfhost/xwiki
docker compose exec -T postgres pg_dump -U xwiki -d xwiki | gzip > ~/selfhost/xwiki/backups/xwiki-db-$(date +%F).sql.gz
tar -C ~/selfhost/xwiki -czf ~/selfhost/xwiki/backups/xwiki-files-$(date +%F).tar.gz compose.yml .env data
ls -lh ~/selfhost/xwiki/backups/
```

Assert: both exist, neither is empty, both sizes printed. Nothing goes offline: `pg_dump`
snapshots a running database consistently. On Linux the container wrote `data` as root; those
files stay readable, and if `tar` prints `Permission denied`, rerun that line with `sudo`.

Both 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 off this computer, a sync folder or a USB
stick, and copy both there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert: both
names are listed there, or this install has no backup.

To restore: `docker compose down -v`, the one place `-v` belongs because it drops the old
database volume on purpose. Delete `data`, untar the archive into ~/selfhost/xwiki so `.env`
and the permanent directory are back before any container starts, because PostgreSQL takes
`DB_PASSWORD` from `.env` the moment it initialises an empty volume. Then
`docker compose up -d postgres`, wait 30 seconds for healthy, pipe `gunzip -c` on the `.sql.gz`
into `docker compose exec -T postgres psql -U xwiki -d xwiki`, then `docker compose up -d`.
Both matter: the dump alone restores a wiki whose every attachment is a broken link.

## 9. Updating later

New versions are listed at https://github.com/xwiki/xwiki-platform/releases, and the tag each
maps to is in https://github.com/docker-library/official-images/blob/master/library/xwiki. Stay
on 17.10.x, the long-term line; 18.x is the monthly train. Back up first, then edit the image
line in ~/selfhost/xwiki/compose.yml to the new tag and digest:

```bash
cd ~/selfhost/xwiki
docker compose pull
docker compose up -d
docker compose logs --tail 40 xwiki
```

XWiki migrates its own schema on the way up, and a version bump brings the wizard back for its
user-interface step. Watch that log settle, then re-run step 7's checks.

## 10. What will probably go wrong

I closed the laptop lid during the wizard's user-interface step and came back to a browser tab
that had given up, and read that as a broken install. It was not. That step is a download from
upstream's extension repository taking minutes, and this machine has to stay awake and online
for all of it, which a laptop on battery does not do by itself. Reloading
http://localhost:8172/ puts the wizard back where it got to. Docker Desktop wants the same
afterwards: nothing answers on 8172 until it is running, so after a reboot start it first.

## 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 rebind 8172 to 0.0.0.0 so a phone can reach it. That puts a wiki with one password on
  every network the user joins.
- Do not configure SMTP. XWiki runs without it, and every notification it would have emailed
  stays inside the web interface.
- Do not install the Confluence import extension. It is a contributed extension, added from
  the Extension Manager once there is a wiki to import into.
compose.local.ymlthe services, pinned · local layout65 lines

authored from upstream docs, never pasted · 2,691 bytes

# XWiki · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   image tags .... https://github.com/docker-library/official-images/blob/master/library/xwiki
#   image sources . https://github.com/xwiki/xwiki-docker/tree/master/17/postgres-tomcat
#
# Two services, every path relative to ~/selfhost/xwiki/ so one file works on
# macOS, Linux and Windows. The database is a named volume, not a bind mount,
# because PostgreSQL chowns its data directory to its own uid and a Windows
# home bind cannot allow that. The permanent directory stays a bind mount: it
# holds the flavor, the Solr index and every attachment, file storage having
# been XWiki's attachment default since 10.5. `init: true` reaps LibreOffice's
# orphans. 17.10.11 is the LTS line. Digests read 2026-08-07; both multi-arch.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: xwiki-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: xwiki
      POSTGRES_USER: xwiki
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      # Upstream initialises it this way; encoding is fixed at create time.
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale-provider=builtin --locale=C.UTF-8"
    volumes:
      - xwiki-pgdata:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xwiki -d xwiki"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  xwiki:
    image: xwiki:17.10.11-postgres-tomcat@sha256:f1b36072ed82d2e7414b3b12b670d4ddf981f32698f70a8fa8deb606fe989621
    container_name: xwiki
    restart: unless-stopped
    init: true
    # DB_PASSWORD arrives from ./.env, mode 600. The entrypoint writes it
    # and the three below into WEB-INF/hibernate.cfg.xml.
    env_file: ./.env
    environment:
      DB_HOST: postgres
      DB_DATABASE: xwiki
      DB_USER: xwiki
      # No JAVA_OPTS: setenv.sh gives Tomcat a 1024 MB heap, and JAVA_OPTS
      # is where you raise it.
    volumes:
      - ./data:/usr/local/xwiki
    healthcheck:
      # Tomcat boots, then XWiki builds its schema: long start period.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/bin/view/Main/ || exit 1"]
      interval: 15s
      retries: 20
      start_period: 300s
    ports:
      # Loopback only: no other device on the wifi can reach 8172.
      - "127.0.0.1:8172:8080"
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  xwiki-pgdata:

agent-readable mirror: /self-host/confluence.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, pinned63 lines

authored from upstream docs, never pasted · 2,759 bytes

# XWiki · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   image tags .... https://github.com/docker-library/official-images/blob/master/library/xwiki
#   image sources . https://github.com/xwiki/xwiki-docker/tree/master/17/postgres-tomcat
#
# Two services: XWiki on Tomcat, and the PostgreSQL that holds every page.
# 17.10.11 is the line the official-images file above labels LTS; the 18.x tags
# are the monthly stable line. The image carries the XWiki WAR but not the
# default set of wiki pages, so the wizard downloads those on first boot into
# the permanent directory mounted below, which also holds the Solr index and
# every attachment: file storage has been XWiki's attachment default since
# 10.5. `init: true` reaps the LibreOffice children the JVM leaves as zombies,
# as upstream does. Digests read 2026-08-07; both publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  postgres:
    image: postgres:18.4-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15
    container_name: xwiki-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: xwiki
      POSTGRES_USER: xwiki
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      # Upstream initialises it this way; encoding is fixed at create time.
      POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale-provider=builtin --locale=C.UTF-8"
    volumes:
      - /srv/xwiki/postgres:/var/lib/postgresql
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U xwiki -d xwiki"]
      interval: 10s
      retries: 12
    # No `ports:` at all: 5432 is reachable only from the other container.

  xwiki:
    image: xwiki:17.10.11-postgres-tomcat@sha256:f1b36072ed82d2e7414b3b12b670d4ddf981f32698f70a8fa8deb606fe989621
    container_name: xwiki
    restart: unless-stopped
    init: true
    # DB_PASSWORD arrives from /srv/xwiki/.env, mode 600. The entrypoint
    # writes it and the three below into WEB-INF/hibernate.cfg.xml.
    env_file: /srv/xwiki/.env
    environment:
      DB_HOST: postgres
      DB_DATABASE: xwiki
      DB_USER: xwiki
      # No JAVA_OPTS: setenv.sh gives Tomcat a 1024 MB heap, and JAVA_OPTS
      # is where you raise it.
    volumes:
      - /srv/xwiki/data:/usr/local/xwiki
    healthcheck:
      # Tomcat boots, then XWiki builds its schema: long start period.
      test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8080/bin/view/Main/ || exit 1"]
      interval: 15s
      retries: 20
      start_period: 300s
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8172.
      - "127.0.0.1:8172:8080"
    depends_on:
      postgres:
        condition: service_healthy
Caddyfilethe hostname and TLS29 lines

authored from upstream docs, never pasted · 1,076 bytes

# XWiki · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/docker-compose.yml 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. XWiki builds the
# links inside a page relative to the request it answered, so it follows this
# hostname without being told it.

<DOMAIN> {
	# Pages, search results and the JavaScript bundles are all text.
	encode zstd gzip

	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}

	# 8172 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall. XWiki's realtime
	# editing rides a WebSocket, and reverse_proxy performs that upgrade
	# with no extra directive.
	reverse_proxy 127.0.0.1:8172
}
install.shthe same install, no agent163 lines

authored from upstream docs, never pasted · 7,861 bytes

#!/usr/bin/env bash
# XWiki · 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=wiki.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://github.com/docker-library/official-images/blob/master/library/xwiki
#   https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/Dockerfile
#   https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/docker-compose.yml
#   https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/xwiki/docker-entrypoint.sh
#   https://github.com/xwiki/xwiki-docker/blob/master/17/postgres-tomcat/tomcat/setenv.sh
#
# One secret is generated here, on this machine: the PostgreSQL password. It
# goes into /srv/xwiki/.env with mode 600 and is never printed. There is no
# administrator password to generate, because XWiki seeds no account: the first
# one is registered by a human in the setup wizard, which is where this script
# stops and hands over.
#
# Read the closing summary. Between the moment this script starts the containers
# and the moment you register that account, anybody who reaches DOMAIN_HOST can
# register it instead, because XWiki offers its wizard to a signed-out visitor
# for as long as the wiki has no registered user.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/xwiki}"
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. wiki.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 3072 ] || die "only ${avail_mb} MB of RAM available; Tomcat's 1024 MB heap plus LibreOffice plus PostgreSQL wants 3072 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 ----------------------------------------------------
#
# postgres and data both stay root-owned at 700. The PostgreSQL image chowns its
# own data directory and refuses one somebody claimed first, and the XWiki
# container's Tomcat runs as root, so data becomes root-owned the moment it
# boots. data is XWiki's permanent directory: the user interface the wizard
# downloads, the Solr index, the logs, and every attachment.

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

# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: the value is substituted into a JDBC connection string
# where punctuation helps nobody. Read it later with
#   sudo grep DB_PASSWORD /srv/xwiki/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		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-xwiki"
	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 8172 nor 5432 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; 8172 and 5432 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 -------------------------------------------------------------
#
# Tomcat boots and XWiki builds its entire schema in PostgreSQL before anything
# answers, so the first start is minutes and Caddy returns 502 for all of them.
# Ten minutes of patience is budgeted below. `/` redirects to /bin/view/Main/,
# which redirects again to the setup wizard, so curl follows two hops.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/ (a first boot takes minutes)"
for _ in $(seq 1 60); do
	code="$(curl -sSL -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "https://${DOMAIN_HOST}/ answered ${code:-nothing}. Check: docker compose logs --tail 60 xwiki"

curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'id="distributionWizard"' \
	|| die "the first page is not the setup wizard. Check: docker compose logs --tail 60 xwiki"

curl -sSL "https://${DOMAIN_HOST}/" | grep -q 'Distribution Wizard' \
	|| die "the wizard page did not render its Distribution Wizard heading"

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

STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -T postgres pg_dump -U xwiki -d xwiki | gzip > "$APP_DIR/backups/xwiki-db-${STAMP}.sql.gz"
sudo tar -czf "$APP_DIR/backups/xwiki-files-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env data -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/xwiki-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/xwiki-files-${STAMP}.tar.gz" ] || die "the file archive is empty"

cat <<-DONE

	XWiki is answering at https://${DOMAIN_HOST}/ with its setup wizard.

	  1. Open https://${DOMAIN_HOST}/ in a browser now, not later. The first
	     screen is headed "Distribution Wizard". Its second step is headed
	     "Admin user": register the account you will administer this wiki
	     with, and put that password in your password manager as you type it.
	     No mail is configured, so there is no password-reset route.
	     Until that account exists, anyone who reaches this hostname can
	     register it instead of you.
	  2. Then let the "User Interface" step finish. It downloads the default
	     flavor from upstream's extension repository and takes minutes on a
	     progress bar that barely moves. That is the install working.
	  3. Then confirm the wizard closed behind you, from this server:
	       curl -sSL https://${DOMAIN_HOST}/bin/distribution/XWiki/Distribution \\
	         | grep -c 'id="distributionWizard"'
	     It must print 0.
	  4. The database password is in $APP_DIR/.env, mode 600, and was not
	     printed here. Read it with
	       sudo grep DB_PASSWORD $APP_DIR/.env
	  5. A first backup is in $APP_DIR/backups: a database dump and a file
	     archive holding compose.yml, .env, the permanent directory and the
	     live Caddy site block. Both were taken before the wizard ran, so run
	     the same two commands again once step 2 is done, then copy them off
	     this box. Attachments live on disk in that permanent directory, not
	     in the database, so a dump on its own is half a backup.

DONE

What 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 Confluence.

  • The first boot is a download, not an unpack. The official image ships the XWiki WAR and nothing else, so the setup wizard fetches the default set of wiki pages from upstream's extension repository the first time you open the site: minutes on a progress bar, and a server with no outbound internet cannot finish the install at all.
  • Whoever opens the URL first becomes the administrator. XWiki hands its setup wizard to a signed-out visitor for exactly as long as the wiki has no registered user, which is what makes walking away from a half-finished install on a hostname that already resolves a real risk rather than a theoretical one.
  • Two backup artifacts, not one. Pages, revisions and comments are rows in PostgreSQL, but attachment content has been files on disk since XWiki 10.5, in the permanent directory. A dump restored without that directory gives you every page back with every attachment a broken link.
  • It is Java and it wants the memory. Tomcat starts with a 1 GB heap and the same container runs LibreOffice for office import and export, with PostgreSQL beside it, so the cheap 2 GB VPS tier is the wrong box and the symptom is an OOM kill rather than a message about memory.
  • Confluence import is a contributed extension, not a shipped feature. xwiki-contrib/confluence reads Confluence's XML export and is actively maintained, but you install it yourself from the Extension Manager afterwards, and it converts storage format and page structure rather than promising macro-for-macro fidelity.

Where this came from

“The installation process requires internet access and it might take a few minutes to complete depending on the internet bandwidth and the load of the remote extension repository.”

  • The official image downloads only the XWiki WAR at build time, so the default set of wiki pages is not in the image and the setup wizard fetches it from upstream's extension repository on the first boot. source
  • XWiki gives a signed-out visitor access to the Distribution Wizard only while the main wiki has no registered user, and refuses it as soon as one exists. source
  • Attachment content has defaulted to filesystem storage rather than the database since XWiki 10.5, which puts uploaded files in the permanent directory the image mounts at /usr/local/xwiki. source
  • The image starts Tomcat with a 1024 MB heap because upstream states the container default of 512 MB is not enough for XWiki, and JAVA_OPTS is the documented way to raise it. source
  • The 17.10.x tags are the long-term-support line and the 18.x tags are the current stable line, both published for amd64 and arm64v8. source

Questions people actually ask

Answered from this page's own data — the same numbers, in sentences.

  • Can I self-host Confluence?

    Not Confluence 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 XWiki. A structured enterprise wiki with spaces, macros and per-page rights, on a hostname you own and a database you can dump. 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 120 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 Confluence?

    XWiki. A structured enterprise wiki with spaces, macros and per-page rights, on a hostname you own and a database you can dump. The one on this list built to the same shape as Confluence rather than to a lighter one. Structured spaces, macros you can write in the page itself, a rights model that goes per space and per page, and an extension manager that installs the rest: two decades of the same idea, LGPL, with your content in a PostgreSQL you can dump. The costs are honest ones. It is Java, so it wants 3 GB rather than 1, the first boot downloads its own interface before it will show you a page, and its default look is a wiki rather than a modern document editor. Also evaluated and ranked below it: Docmost, BookStack. The prompt on this page installs XWiki only. XWiki is LGPL-2.1-or-later-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Confluence?

    3072 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. XWiki itself is free and LGPL-2.1-or-later-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Confluence Standard, $33.50/mo — $402 a year, 5 seats assumed.

  • 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 XWiki install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run XWiki 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 XWiki 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: The wiki answers only at http://localhost:8172, so nobody you would share a space with can open it and neither can your own phone: on this path a team wiki is a notebook for one. 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.