# Can I self-host Google Workspace?

**YES** — it's called Collabora Online. ONE COMMAND setup · ~10 minutes to running · 2 GB RAM minimum · $42/mo you stop paying ($504/yr on the Business Starter plan, 5 seats assumed).

Collabora Online authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/google-workspace-business-starter/

## Install prompt (Claude Code)

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

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

Install Collabora Online Development Edition 26.04.2.4.1 on that server, reachable at
https://<DOMAIN>, behind the existing Caddy with automatic TLS.

## 1. Preflight

Say this to the user before anything installs, because it decides whether they want this at
all. Collabora Online is the editing engine, not a place to keep files. On its own it edits
nothing: it opens, renders and saves documents another application hands it. Install it if the
user already runs Nextcloud, or another application with a Collabora connector, or is about to.
Otherwise this leaves them a correctly running server with nothing pointed at it.

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, and it has to resolve for the other
application's own server as well as for every user's browser, because both talk to it.

This install needs 2048 MB of RAM available and 10 GB free on /srv. The image is about 470 MB
compressed and unpacks to several times that: it carries a whole office suite, every dictionary
and forty language packs. It publishes amd64 and arm64. Measure everything 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 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope: each open document is a separate process here, so a machine short of
memory fails on the third document, not at startup. If `dig +short` prints nothing, print that
and stop.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user. There is no `data` directory and no
volume in step 4, because there is nothing here to keep: coolwsd builds its chroot jails and
its cache inside the container, and the upstream source turns fsync off there with the comment
that this is a state-less container. Two files are the whole install.

## 3. Secrets

One secret: the admin console password. Generate it on the server. Do not print it, do not
repeat it in your summary, and do not put it in any log line. Hex rather than base64, because
the user types this into a browser login box and hex has no characters that invite a typo.

```bash
umask 077
cat > /srv/collabora/.env <<EOF
username=admin
password=$(openssl rand -hex 24)
server_name=<DOMAIN>:443
aliasgroup1=
EOF
chmod 600 /srv/collabora/.env
umask 022
ls -l /srv/collabora/.env
```

Assert: the file exists with mode `-rw-------`. The four names are lowercase because coolwsd
reads them from the environment exactly as written. `username` and `password` become the admin
console credentials. `server_name` is what upstream documents for a server behind a reverse
proxy, so the editor stops guessing its own address from each request. `aliasgroup1` is
deliberately empty and step 7 fills it in: set at all, it switches the WOPI host list into
group mode, and with no group in it upstream's own log line reads
`all WOPI hosts will be denied`. That is the right way round, because the alternative default
lets the first application that connects claim the server, which is a race, not a policy. Tell
the user they can read the password with `sudo grep password /srv/collabora/.env`, and that
step 7 needs it.

## 4. compose.yml

```bash
cat > /srv/collabora/compose.yml <<'EOF'
# Collabora Online (CODE) · the deterministic fallback. Authored by
# caniselfhostit from the upstream sources, not copied from a repository:
#   image build ........ https://github.com/CollaboraOnline/online.mirror/blob/main/docker/from-packages/Dockerfile
#   env var handling ... https://github.com/CollaboraOnline/online.mirror/blob/main/wsd/COOLWSD.cpp
#   config reference ... https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in
#
# One service, and no second one hiding inside it. No database, no message
# broker, and no volume: coolwsd builds its jails and its cache inside the
# container, and the source turns fsync off there with the comment that this is
# a state-less container. Your documents live in whichever application hands
# them to this server, not here.
#
# No healthcheck is declared here, on purpose. The 26.04 image is built on a
# distroless base with no shell, so a CMD-SHELL probe of ours would have
# nothing to run it. It ships `coolwsd --probe --use-env-vars` instead.
#
# Tag and digest read from the registry on 2026-08-07; amd64 and arm64 both
# published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  collabora:
    image: collabora/code:26.04.2.4.1@sha256:1f864ce3f0c49e867787b6dd303bd6ba989542d3023f6809df558eafd04c1b97
    container_name: collabora
    restart: unless-stopped
    env_file: /srv/collabora/.env
    environment:
      # Caddy terminates TLS on this box, so coolwsd serves plain http on its
      # own socket and is told the client reached it over https. With
      # ssl.enable false the self-signed certificate branch never runs at all.
      extra_params: "--o:ssl.enable=false --o:ssl.termination=true"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8165.
      - "127.0.0.1:8165:9980"
    # SIGTERM has to leave coolwsd time to save and upload whatever is still
    # open in an editor. Upstream's own deployment allows the same 60 seconds.
    stop_grace_period: 60s
EOF
cd /srv/collabora && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. The container listens on plain http 9980 inside, published
only on 127.0.0.1:8165.

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-collabora
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Collabora Online (CODE) · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in and
# 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 server_name in .env, and it is the address you type into the application
# that uses this editor, so it must resolve for that application's server too.

<DOMAIN> {
	# The editors are tens of megabytes of JavaScript, served by coolwsd's
	# own file server rather than by anybody's CDN.
	encode zstd gzip

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

	# There is deliberately no X-Frame-Options and no frame-ancestors rule.
	# This whole product is an iframe: the application that owns the document
	# embeds the editor inside its own page, and a frame-blocking header
	# would leave the user looking at an empty box. The WOPI alias group in
	# .env, not the browser, decides which application may load a document.

	# reverse_proxy sets X-Forwarded-Proto and X-Forwarded-Host on the way
	# through, which is how coolwsd builds https URLs while speaking plain
	# http here, and it upgrades WebSocket connections with no extra
	# configuration. Every keystroke in a shared document is a WebSocket
	# frame. 8165 is the loopback port compose publishes; it is not open in
	# the firewall.
	reverse_proxy 127.0.0.1:8165
}
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-collabora, reload, and report what it objected to. Caddy requests
the certificate on the first request and renews it itself, 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, 443/udp
is HTTP/3. 8165 stays closed: it is bound to 127.0.0.1 and Caddy reaches it over loopback.
Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and 443/udp, and no
rule mentioning 8165.

## 7. Start and verify

The image is about 470 MB compressed, so the pull takes a few minutes, and the first start
scans the fonts and dictionaries before anything answers.

```bash
cd /srv/collabora
docker compose pull
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS https://<DOMAIN>/ || true); echo "$i $body"; [ "$body" = "OK" ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/hosting/discovery
curl -sS https://<DOMAIN>/hosting/discovery | grep -c 'wopi-discovery'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/browser/dist/admin/admin.html
```

Assert, all four, and print what you received for each. The loop ends printing `OK`, the plain
text body upstream's own readiness probe reads from `/`. The discovery request returns `200`.
The grep prints at least `1`. The admin console returns `401`, and that is the security assert:
the console is reachable and refuses anyone without the password from step 3. If any of the
four misses, stop, run `docker compose logs --tail 40 collabora`, and name the likely earlier
step. A `502` from Caddy in the first minutes is a container still starting; a `200` from the
admin console means .env never reached it and the console is open to the internet, which is a
stop-everything result.

The first screen is https://<DOMAIN>/hosting/discovery, an XML document whose opening element
reads `<wopi-discovery>`. The admin console at
https://<DOMAIN>/browser/dist/admin/admin.html asks for the username `admin` and the password
from step 3, and its dashboard heading then reads `Dashboard`. A running container is not
success.

STOP: tell the user nothing is editing a document yet, ask them for the address of the
application that will use this editor, and wait. Do not continue until they answer, or say they
will connect it later. If they answer, put that address in the alias group and restart:

```bash
sed -i 's|^aliasgroup1=$|aliasgroup1=https://cloud.example.com:443|' /srv/collabora/.env
cd /srv/collabora && docker compose up -d --force-recreate
sleep 20
grep -c '^aliasgroup1=https' /srv/collabora/.env
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/hosting/discovery
```

Replace `cloud.example.com` with the hostname the user gave, keeping the scheme and the `:443`.
Assert: the grep prints `1` and the discovery request prints `200`. Until that line is filled
in, this server denies every application that asks it for a document, which is a safe state
rather than a fault. Then hand the user these steps for Nextcloud: install the app named
`Collabora Online` from Apps, open `/settings/admin/richdocuments`, put `https://<DOMAIN>` in
the Collabora Online server field, and save. Nextcloud keeps that as the `wopi_url` setting and
fetches `/hosting/discovery` from it at once.

## 8. First backup and restore

One archive, and it is small, because there is no data here: the documents are in the other
application and the container keeps nothing. What is irreplaceable is the pair of files that
rebuild this service, the password and alias group in `.env` and the pinned digest in
`compose.yml`.

```bash
cd /srv/collabora
sudo tar -czf /srv/collabora/backups/collabora-config-$(date +%F).tar.gz -C /srv/collabora compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/collabora/backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped. A backup on
the same disk as the data is not a backup, so run this from the user's machine, not the server:

```bash
mkdir -p ~/backups/collabora
scp vps:/srv/collabora/backups/*.tar.gz ~/backups/collabora/
```

To restore on a fresh box: recreate the directories as in step 2, untar the archive into
/srv/collabora, put the `Caddyfile` member at /etc/caddy with `<DOMAIN>` substituted, reload
Caddy, then `docker compose up -d` and re-run step 7's four asserts. Tell the user that is the
whole disaster plan, and that the member that matters is `.env`.

## 9. Updating later

New tags are listed at https://hub.docker.com/r/collabora/code/tags and what changed is at
https://www.collaboraonline.com/code-26-04-release-notes/. Back up first, then edit the image
line in /srv/collabora/compose.yml to the new tag and its digest:

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

Watch that log until it settles, then re-run step 7's four asserts before calling the update
done. A major version changes the editor bundle the connected application loads, so open one
real document afterwards as well.

## 10. What will probably go wrong

The connection to Nextcloud will fail with a message that says nothing useful. I saved the
server address there, got `Collabora Online is not reachable`, and spent twenty minutes on
Caddy and DNS, both of which were fine. The editor was refusing on purpose: the alias group in
`.env` was still empty, so every WOPI host was denied, and that refusal happens deep enough
that the connector only reports a generic failure. If a connector cannot reach a server whose
`/hosting/discovery` answers `200` in your own browser, read
`docker compose logs --tail 40 collabora` and look for the alias-group line first.

## 11. Out of scope

- Do not set `DONT_GEN_SSL_CERT` or `cert_domain`. This install runs with `ssl.enable=false`
  behind Caddy, so the self-signed certificate branch never runs and neither variable has
  anything to do.
- Do not delete the `aliasgroup1` line to make a connector work. Removing it hands the server
  to whichever application connects first, and on a public hostname that is a race with
  strangers.
- Do not set `remoteconfigurl`. It points this server at a URL it fetches configuration from on
  every restart, which hands the install to whoever controls that URL.
- Do not install Nextcloud here. This prompt installs the editor; the application in front of
  it is a separate install with its own hostname.
````

## Chat fallback

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

You are installing Collabora Online Development Edition 26.04.2.4.1 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, because it decides whether you want the install at all. Collabora
Online is the editing engine, not a place to keep files. On its own it edits nothing: it opens,
renders and saves documents that another application hands to it. It is worth installing if you
already run Nextcloud, or another application with a Collabora connector, or are about to. If
you have none of those, what you will have at the end is a correctly running server with
nothing pointed at it.

## 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 hostname that
does not resolve and failed attempts count against a rate limit you cannot see. Under 2048 MB
of memory, resize the box before you go on: every open document here is a separate process, so
a machine that is short of memory does not fail at startup, it fails on the third document,
which looks like a bug in the editor rather than a bug in the shopping. That hostname also has
to resolve for the other application's own server, not only for your browser, so a split-horizon
DNS setup that answers differently inside your network will bite you at step 7.

## 2. Layout

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

You should see: one directory, `backups`, owned by you.

If you do not: `Permission denied` means you are not in the sudoers group, which Prompt Zero
set up. There is no `data` directory here and no volume in step 4, because there is nothing to
keep: coolwsd builds its chroot jails and its cache inside the container, and the upstream
source turns fsync off there with the comment that this is a state-less container. Two files
under this directory are the whole install.

## 3. Secrets

One secret: the admin console password. It is generated here, on the server, straight into a
file only you can read. Hex rather than base64, because you type this into a browser login box
and hex has no characters that invite a typo.

```bash
umask 077
cat > /srv/collabora/.env <<EOF
username=admin
password=$(openssl rand -hex 24)
server_name=<DOMAIN>:443
aliasgroup1=
EOF
chmod 600 /srv/collabora/.env
umask 022
ls -l /srv/collabora/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the third 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/collabora/.env` and
carry on. If the file already existed from an earlier attempt, this block has now replaced the
password, and the admin console will only accept the new one.

Do not paste that file, the password, or any output containing it into this chat window. Read
it once at step 7 with `sudo grep password /srv/collabora/.env`, put it in your password
manager, and keep it out of anything you are typing to a chatbot.

Those four names are lowercase because coolwsd reads them from the environment exactly as
written. `username` and `password` become the admin console credentials. `server_name` is what
upstream documents for a server behind a reverse proxy, so the editor stops guessing its own
address from each request. `aliasgroup1` is deliberately empty and step 7 fills it in: set at
all, it switches the WOPI host list into group mode, and with no group in it upstream's own log
line for that state reads `all WOPI hosts will be denied`. That is the right way round. The
alternative default lets the first application that connects claim your server, which is a
race, not a policy.

## 4. compose.yml

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

```bash
cat > /srv/collabora/compose.yml <<'EOF'
# Collabora Online (CODE) · the deterministic fallback. Authored by
# caniselfhostit from the upstream sources, not copied from a repository:
#   image build ........ https://github.com/CollaboraOnline/online.mirror/blob/main/docker/from-packages/Dockerfile
#   env var handling ... https://github.com/CollaboraOnline/online.mirror/blob/main/wsd/COOLWSD.cpp
#   config reference ... https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in
#
# One service, and no second one hiding inside it. No database, no message
# broker, and no volume: coolwsd builds its jails and its cache inside the
# container, and the source turns fsync off there with the comment that this is
# a state-less container. Your documents live in whichever application hands
# them to this server, not here.
#
# No healthcheck is declared here, on purpose. The 26.04 image is built on a
# distroless base with no shell, so a CMD-SHELL probe of ours would have
# nothing to run it. It ships `coolwsd --probe --use-env-vars` instead.
#
# Tag and digest read from the registry on 2026-08-07; amd64 and arm64 both
# published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  collabora:
    image: collabora/code:26.04.2.4.1@sha256:1f864ce3f0c49e867787b6dd303bd6ba989542d3023f6809df558eafd04c1b97
    container_name: collabora
    restart: unless-stopped
    env_file: /srv/collabora/.env
    environment:
      # Caddy terminates TLS on this box, so coolwsd serves plain http on its
      # own socket and is told the client reached it over https. With
      # ssl.enable false the self-signed certificate branch never runs at all.
      extra_params: "--o:ssl.enable=false --o:ssl.termination=true"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8165.
      - "127.0.0.1:8165:9980"
    # SIGTERM has to leave coolwsd time to save and upload whatever is still
    # open in an editor. Upstream's own deployment allows the same 60 seconds.
    stop_grace_period: 60s
EOF
cd /srv/collabora && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/collabora/.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/collabora/compose.yml` and paste again in one go. The container listens on plain
http port 9980 inside and is published only on 127.0.0.1:8165, because Caddy on the host
terminates TLS. There is no volume in that file and that is not an omission: your documents
live in the application that hands them over, and this container keeps nothing of its own.

## 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-collabora
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Collabora Online (CODE) · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in and
# 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 server_name in .env, and it is the address you type into the application
# that uses this editor, so it must resolve for that application's server too.

<DOMAIN> {
	# The editors are tens of megabytes of JavaScript, served by coolwsd's
	# own file server rather than by anybody's CDN.
	encode zstd gzip

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

	# There is deliberately no X-Frame-Options and no frame-ancestors rule.
	# This whole product is an iframe: the application that owns the document
	# embeds the editor inside its own page, and a frame-blocking header
	# would leave the user looking at an empty box. The WOPI alias group in
	# .env, not the browser, decides which application may load a document.

	# reverse_proxy sets X-Forwarded-Proto and X-Forwarded-Host on the way
	# through, which is how coolwsd builds https URLs while speaking plain
	# http here, and it upgrades WebSocket connections with no extra
	# configuration. Every keystroke in a shared document is a WebSocket
	# frame. 8165 is the loopback port compose publishes; it is not open in
	# the firewall.
	reverse_proxy 127.0.0.1:8165
}
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-collabora /etc/caddy/Caddyfile`,
reload, and paste again. The most common cause is a `<DOMAIN>` you replaced in one place and
not the other. Caddy requests the certificate on the first request and renews it on its own, so
there is nothing to schedule.

## 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 `8165`.

If you do not: delete anything for `8165` with `sudo ufw delete allow 8165`. That port is bound
to 127.0.0.1 by the compose file, so Caddy reaches it over loopback and nothing else can reach
it at all. 80/tcp is there to redirect to HTTPS and to answer 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

The image is about 470 MB compressed, so the pull takes a few minutes, and the first start
scans the fonts and dictionaries before anything answers.

```bash
cd /srv/collabora
docker compose pull
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS https://<DOMAIN>/ || true); echo "$i $body"; [ "$body" = "OK" ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/hosting/discovery
curl -sS https://<DOMAIN>/hosting/discovery | grep -c 'wopi-discovery'
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/browser/dist/admin/admin.html
```

You should see, in order: the loop reaching `OK`, then `200`, then a count of at least `1`,
then `401`.

If you do not: the `401` is the one worth understanding. The admin console is reachable and
refusing a request that carries no credentials, which means the password from step 3 reached
the container and is in force. A `200` in its place is the opposite result and a
stop-everything one: it means the console is open to anyone who finds your hostname. Check that
`.env` reached the container with `docker compose config`. If the loop never reaches `OK`, give
it the full thirty attempts before doing anything: a `502` from Caddy in the first few minutes
is a container that is still starting, not a broken install. After that,
`docker compose logs --tail 40 collabora` is the place to look.

The first screen is https://<DOMAIN>/hosting/discovery, an XML document whose opening element
reads `<wopi-discovery>`. That is the file every connector fetches first. The admin console at
https://<DOMAIN>/browser/dist/admin/admin.html asks for the username `admin` and the password
from step 3, and its dashboard heading then reads `Dashboard`. A running container is not
success; those four asserts are.

Now read the password once and put it in your password manager:

```bash
sudo grep password /srv/collabora/.env
```

You should see: one line, the variable name followed by 48 characters of hex. Do not paste that
line into this chat.

Nothing is editing a document yet, and one line still has to be filled in. Decide the address
of the application that will use this editor, then:

```bash
sed -i 's|^aliasgroup1=$|aliasgroup1=https://cloud.example.com:443|' /srv/collabora/.env
cd /srv/collabora && docker compose up -d --force-recreate
sleep 20
grep -c '^aliasgroup1=https' /srv/collabora/.env
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/hosting/discovery
```

You should see: `1`, then `200`. Replace `cloud.example.com` with your own hostname, keeping
the scheme and the `:443`.

If you do not: a `0` from the grep means the pattern did not match, usually because the line
already had a value from an earlier attempt; open the file and set it by hand. Until that line
holds a real address this server denies every application that asks it for a document, which is
a safe state to be left in rather than a fault. To connect Nextcloud: install the app named
`Collabora Online` from Apps, open the admin page at `/settings/admin/richdocuments`, put
`https://<DOMAIN>` in the Collabora Online server field, and save. Nextcloud keeps that as the
`wopi_url` setting and fetches `/hosting/discovery` from it straight away.

## 8. First backup and restore

One archive, and it is small, because there is no data here: the documents are in the other
application and the container keeps nothing. What is irreplaceable is the pair of files that
rebuild this service, the password and alias group in `.env` and the pinned digest in
`compose.yml`.

```bash
cd /srv/collabora
sudo tar -czf /srv/collabora/backups/collabora-config-$(date +%F).tar.gz -C /srv/collabora compose.yml .env -C /etc/caddy Caddyfile
ls -lh /srv/collabora/backups/
```

You should see: one file, a few kilobytes. Nothing goes offline.

If you do not: run `tar -tzf` on the finished archive to list what is inside it. `compose.yml`,
`.env` and `Caddyfile` should all be there, and if `Caddyfile` is missing then tar never
reached the second `-C` because the first path was wrong.

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/collabora
scp vps:/srv/collabora/backups/*.tar.gz ~/backups/collabora/
```

You should see: one file copied, and it listed by `ls -lh ~/backups/collabora/`.

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 nothing is at stake:

```bash
cd /srv/collabora
docker compose down
sudo rm -f /srv/collabora/compose.yml /srv/collabora/.env
sudo tar -xzf /srv/collabora/backups/collabora-config-$(date +%F).tar.gz -C /srv/collabora --exclude Caddyfile
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS https://<DOMAIN>/ || true); echo "$i $body"; [ "$body" = "OK" ] && break; sleep 10; done
```

You should see: the loop reaching `OK` again, which means two deleted files came back and the
service came back with them.

If you do not: the archive also contains a `Caddyfile` member, and `--exclude Caddyfile` keeps
it out of /srv/collabora where it would do nothing. On a genuinely fresh box you would put that
member at /etc/caddy/Caddyfile instead, with `<DOMAIN>` already replaced, and reload Caddy.
That is the whole disaster plan: two files back in place and one `docker compose up -d`. The
member that matters is `.env`, because losing it means a new password and a connector that has
to be told about it.

## 9. Updating later

New tags are listed at https://hub.docker.com/r/collabora/code/tags and what changed is at
https://www.collaboraonline.com/code-26-04-release-notes/. Take the backup first, then edit the
`image:` line in /srv/collabora/compose.yml to the new tag and its digest.

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

You should see: the start-up sequence, then no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
four asserts from step 7 before you call the update done, and open one real document in the
connected application as well, because a major version changes the editor bundle that
application loads and a server that answers `OK` can still be serving a bundle the connector
does not understand.

## 10. What will probably go wrong

The connection to Nextcloud will fail with a message that says nothing useful. I saved the
server address there, got `Collabora Online is not reachable`, and spent twenty minutes on
Caddy and DNS, both of which were fine. The editor was refusing on purpose: the alias group in
`.env` was still empty, so every WOPI host was denied, and that refusal happens deep enough
that the connector only reports a generic failure. If a connector cannot reach a server whose
`/hosting/discovery` answers `200` in your own browser, read
`docker compose logs --tail 40 collabora` and look for the alias-group line first.

## 11. Out of scope

- Do not set `DONT_GEN_SSL_CERT` or `cert_domain`. This install runs with `ssl.enable=false`
  behind Caddy, so the self-signed certificate branch never runs and neither variable has
  anything to do.
- Do not delete the `aliasgroup1` line to make a connector work. Removing it hands the server
  to whichever application connects first, and on a public hostname that is a race with
  strangers.
- Do not set `remoteconfigurl`. It points this server at a URL it fetches configuration from on
  every restart, which hands the install to whoever controls that URL.
- Do not install Nextcloud here. This prompt installs the editor; the application in front of
  it is a separate install with its own hostname.
````

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

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

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

Install Collabora Online Development Edition 26.04.2.4.1 under ~/selfhost/collabora, answering
at http://localhost:8165.

## 1. Preflight

Say this before step 2 runs; it decides whether they want this install at all. Collabora Online
is an editing engine, not a place to keep files: on its own it edits nothing, it renders and
saves documents another application hands it. That application has to be on this same computer,
because the editor answers at http://localhost:8165, which means "this machine" wherever it is
read, and nobody else can open a document alongside them.

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. On Linux the
distribution ID and codename print next, for step 2. This install needs 2048 MB of RAM
available and 10 GB free on the home disk; amd64 and arm64 are both published. On macOS and
Windows the figure printed is the host's, and Docker Desktop takes its allocation out of it. If
available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Each
open document is a separate process here, so a machine short of memory fails on the third
document, not at startup.

## 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/collabora/backups
ls -la ~/selfhost/collabora
```

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder and no ownership
fix on any of the three systems, because step 5 declares no volume at all: coolwsd builds its
jails and its cache inside the container, which the source calls state-less. Two files are the
whole install.

## 4. Secrets

One secret: the admin console password. Generate it here, print it nowhere, and keep it out of
your summary and any log line. Hex rather than base64, because the user types this into a
browser login box and hex has no characters that invite a typo.

```bash
umask 077
cat > ~/selfhost/collabora/.env <<EOF
username=admin
password=$(openssl rand -hex 24)
server_name=localhost:8165
aliasgroup1=
EOF
chmod 600 ~/selfhost/collabora/.env
umask 022
ls -l ~/selfhost/collabora/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same
on all three. The four names are lowercase because coolwsd reads them from the environment
exactly as written. `username` and `password` become the admin console credentials.
`aliasgroup1` is deliberately empty and step 7 fills it in: set at all, it switches the WOPI
host list into group mode, and with no group in it upstream's own log line reads
`all WOPI hosts will be denied`. The alternative default lets the first application that
connects claim the server. The user reads the password with `grep password .env` here.

On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/collabora/compose.yml <<'EOF'
# Collabora Online (CODE) · the deterministic fallback for the local path.
# Authored by caniselfhostit from the upstream sources, not copied:
#   image build ........ https://github.com/CollaboraOnline/online.mirror/blob/main/docker/from-packages/Dockerfile
#   env var handling ... https://github.com/CollaboraOnline/online.mirror/blob/main/wsd/COOLWSD.cpp
#   config reference ... https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in
#
# One service, same tag, digest and host port as the server file, with the env
# file path relative so it works from ~/selfhost/collabora/ on all three
# systems. No volumes and no bind mounts at all: coolwsd builds its jails and
# its cache inside the container, which the source calls state-less. Your
# documents live in whichever application hands them over, not here.
#
# No healthcheck is declared here. The 26.04 image is built on a distroless
# base with no shell, so a CMD-SHELL probe of ours would have nothing to run
# it. It ships `coolwsd --probe --use-env-vars` instead, which GETs /livez.
#
# Digest read on 2026-08-07; amd64 and arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  collabora:
    image: collabora/code:26.04.2.4.1@sha256:1f864ce3f0c49e867787b6dd303bd6ba989542d3023f6809df558eafd04c1b97
    container_name: collabora
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Nothing here terminates TLS, so coolwsd serves plain http and the
      # links it builds say http. With ssl.enable false the self-signed
      # certificate branch never runs, so no throwaway certificate is made.
      extra_params: "--o:ssl.enable=false"
    ports:
      # Loopback only: no other device on the wifi can reach 8165.
      - "127.0.0.1:8165:9980"
    # SIGTERM has to leave coolwsd time to save and upload whatever is still
    # open in an editor. Upstream's own deployment allows the same 60 seconds.
    stop_grace_period: 60s
EOF
cd ~/selfhost/collabora && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`: one service, one published port, no volumes.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule. Each is a decision:

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs shutting.

8165 is bound to 127.0.0.1: this computer, not the user's phone, not a laptop on the same wifi.
That is the point of this path, not a defect in it. Confirm it:

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

Assert: that prints `1`, the published-port line. Nothing else is published.

## 7. Start and verify

The image is about 470 MB compressed, so the pull takes minutes, and the first start scans the
fonts and dictionaries before anything answers.

```bash
cd ~/selfhost/collabora
docker compose pull
docker compose up -d
for i in $(seq 1 30); do body=$(curl -sS http://localhost:8165/ || true); echo "$i $body"; [ "$body" = "OK" ] && break; sleep 10; done
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8165/hosting/discovery
curl -sS http://localhost:8165/hosting/discovery | grep -c 'wopi-discovery'
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8165/browser/dist/admin/admin.html
```

Assert all four, and print what you received for each: the loop ends on `OK`, the plain text
body upstream's own readiness probe reads from `/`; discovery returns `200`; the grep prints at
least `1`; the admin console returns `401`, the security assert, because it proves the console
refuses anyone without the password from step 4. If any of the four misses, stop, run
`docker compose logs --tail 40 collabora`, and name the cause: an empty reply in the first
minutes is a container still starting, and a `200` from the console means .env never reached
it. If `port is already allocated` came back, find what holds 8165
(`lsof -nP -iTCP:8165 -sTCP:LISTEN`, or `netstat -ano | findstr :8165` on Windows) and stop
until the user frees it. A running container is not success.

The first screen is http://localhost:8165/hosting/discovery, an XML document whose opening
element reads `<wopi-discovery>`. The admin console at
http://localhost:8165/browser/dist/admin/admin.html asks for the username `admin` and the
step 4 password, and its dashboard heading then reads `Dashboard`.

STOP: tell the user nothing is editing a document yet, ask for the address their Nextcloud
answers on, and wait. Do not continue until they answer, or say they will connect it later.
Then put that address in the alias group and restart:

```bash
sed -i.bak 's|^aliasgroup1=$|aliasgroup1=http://localhost:8080|' ~/selfhost/collabora/.env
cd ~/selfhost/collabora && docker compose up -d --force-recreate
sleep 20
grep -c '^aliasgroup1=http' ~/selfhost/collabora/.env
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8165/hosting/discovery
```

Replace `http://localhost:8080` with the address the user gave, scheme and port included, then
`rm ~/selfhost/collabora/.env.bak`. Assert: the grep prints `1` and discovery prints `200`.
Until then this server denies every application that asks for a document, a safe state rather
than a fault. The user's Nextcloud steps: install the app named `Collabora Online` from Apps,
open `/settings/admin/richdocuments`, put `http://localhost:8165` in the server field, save.
Read them step 10 first: that address is where this goes wrong.

## 8. First backup and restore

One archive of two files, and it is the whole install: the documents are in the other
application and this container keeps nothing. What is irreplaceable is the password and alias
group in `.env`, and the pinned digest in `compose.yml`.

```bash
cd ~/selfhost/collabora
tar -czf backups/collabora-config-$(date +%F).tar.gz compose.yml .env
ls -lh backups/
```

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped.

That archive sits on the same disk as everything else, which is not a backup: on a laptop the
disk and the machine fail together. Ask the user for a destination that leaves this computer, a
folder their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a
Windows drive is written `/d/Backups`, not `D:\Backups`. Assert: the user confirms the filename
is listed there; if they have neither, say plainly this install has no backup.

Prove the restore now, while nothing is at stake. In `~/selfhost/collabora`: `docker compose
down`, `rm -f compose.yml .env`, `tar -xzf backups/collabora-config-$(date +%F).tar.gz`,
`docker compose up -d`, then re-run step 7's loop. Assert: it ends on `OK` again. That is the
whole disaster plan, and anything open in an editor at that moment is lost, though the document
itself is not.

## 9. Updating later

New tags are at https://hub.docker.com/r/collabora/code/tags and what changed is at
https://www.collaboraonline.com/code-26-04-release-notes/. Back up first, then edit the image
line in ~/selfhost/collabora/compose.yml to the new tag and digest:

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

Watch that log until it settles, then re-run step 7's four asserts before calling it done.

## 10. What will probably go wrong

The address will work in the browser and fail in the application anyway. I put
http://localhost:8165 into Nextcloud's Collabora settings, watched the editor load in my own
browser, and got an error saying the server was unreachable. Both were true: my browser was on
this machine, so localhost was this machine, but Nextcloud's server fetches
`/hosting/discovery` from that address too, and if Nextcloud is in a container then localhost
is that container, where nothing listens. Run that application outside a container, or put
both on one Docker network and use the service name in the settings field and the alias group.

## 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 delete the `aliasgroup1` line to make a connector work. Removing it hands the server
  to whichever application connects first, and step 10 is the real problem it hides.
- Do not set `remoteconfigurl`. It fetches configuration from a URL on every restart, which
  hands the install to whoever controls that URL.
- Do not install Nextcloud here. This prompt installs the editor.
````

## docker-compose.yml

```yaml
# Collabora Online (CODE) · the deterministic fallback. Authored by
# caniselfhostit from the upstream sources, not copied from a repository:
#   image build ........ https://github.com/CollaboraOnline/online.mirror/blob/main/docker/from-packages/Dockerfile
#   env var handling ... https://github.com/CollaboraOnline/online.mirror/blob/main/wsd/COOLWSD.cpp
#   config reference ... https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in
#
# One service, and no second one hiding inside it. No database, no message
# broker, and no volume: coolwsd builds its jails and its cache inside the
# container, and the source turns fsync off there with the comment that this is
# a state-less container. Your documents live in whichever application hands
# them to this server, not here.
#
# No healthcheck is declared here, on purpose. The 26.04 image is built on a
# distroless base with no shell, so a CMD-SHELL probe of ours would have
# nothing to run it. It ships `coolwsd --probe --use-env-vars` instead.
#
# Tag and digest read from the registry on 2026-08-07; amd64 and arm64 both
# published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  collabora:
    image: collabora/code:26.04.2.4.1@sha256:1f864ce3f0c49e867787b6dd303bd6ba989542d3023f6809df558eafd04c1b97
    container_name: collabora
    restart: unless-stopped
    env_file: /srv/collabora/.env
    environment:
      # Caddy terminates TLS on this box, so coolwsd serves plain http on its
      # own socket and is told the client reached it over https. With
      # ssl.enable false the self-signed certificate branch never runs at all.
      extra_params: "--o:ssl.enable=false --o:ssl.termination=true"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8165.
      - "127.0.0.1:8165:9980"
    # SIGTERM has to leave coolwsd time to save and upload whatever is still
    # open in an editor. Upstream's own deployment allows the same 60 seconds.
    stop_grace_period: 60s
```

## compose.local.yml

```yaml
# Collabora Online (CODE) · the deterministic fallback for the local path.
# Authored by caniselfhostit from the upstream sources, not copied:
#   image build ........ https://github.com/CollaboraOnline/online.mirror/blob/main/docker/from-packages/Dockerfile
#   env var handling ... https://github.com/CollaboraOnline/online.mirror/blob/main/wsd/COOLWSD.cpp
#   config reference ... https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in
#
# One service, same tag, digest and host port as the server file, with the env
# file path relative so it works from ~/selfhost/collabora/ on all three
# systems. No volumes and no bind mounts at all: coolwsd builds its jails and
# its cache inside the container, which the source calls state-less. Your
# documents live in whichever application hands them over, not here.
#
# No healthcheck is declared here. The 26.04 image is built on a distroless
# base with no shell, so a CMD-SHELL probe of ours would have nothing to run
# it. It ships `coolwsd --probe --use-env-vars` instead, which GETs /livez.
#
# Digest read on 2026-08-07; amd64 and arm64 both published.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  collabora:
    image: collabora/code:26.04.2.4.1@sha256:1f864ce3f0c49e867787b6dd303bd6ba989542d3023f6809df558eafd04c1b97
    container_name: collabora
    restart: unless-stopped
    env_file: ./.env
    environment:
      # Nothing here terminates TLS, so coolwsd serves plain http and the
      # links it builds say http. With ssl.enable false the self-signed
      # certificate branch never runs, so no throwaway certificate is made.
      extra_params: "--o:ssl.enable=false"
    ports:
      # Loopback only: no other device on the wifi can reach 8165.
      - "127.0.0.1:8165:9980"
    # SIGTERM has to leave coolwsd time to save and upload whatever is still
    # open in an editor. Upstream's own deployment allows the same 60 seconds.
    stop_grace_period: 60s
```

## Caddyfile

```text
# Collabora Online (CODE) · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in and
# 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 server_name in .env, and it is the address you type into the application
# that uses this editor, so it must resolve for that application's server too.

<DOMAIN> {
	# The editors are tens of megabytes of JavaScript, served by coolwsd's
	# own file server rather than by anybody's CDN.
	encode zstd gzip

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

	# There is deliberately no X-Frame-Options and no frame-ancestors rule.
	# This whole product is an iframe: the application that owns the document
	# embeds the editor inside its own page, and a frame-blocking header
	# would leave the user looking at an empty box. The WOPI alias group in
	# .env, not the browser, decides which application may load a document.

	# reverse_proxy sets X-Forwarded-Proto and X-Forwarded-Host on the way
	# through, which is how coolwsd builds https URLs while speaking plain
	# http here, and it upgrades WebSocket connections with no extra
	# configuration. Every keystroke in a shared document is a WebSocket
	# frame. 8165 is the loopback port compose publishes; it is not open in
	# the firewall.
	reverse_proxy 127.0.0.1:8165
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Collabora Online (CODE) · 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=office.example.com WOPI_HOST=https://cloud.example.com:443 ./install.sh
#
# Authored by caniselfhostit from the upstream sources:
#   https://github.com/CollaboraOnline/online.mirror/blob/main/docker/from-packages/Dockerfile
#   https://github.com/CollaboraOnline/online.mirror/blob/main/docker/README
#   https://github.com/CollaboraOnline/online.mirror/blob/main/wsd/COOLWSD.cpp
#   https://github.com/CollaboraOnline/online.mirror/blob/main/coolwsd.xml.in
#
# One secret is generated here, on this machine: the admin console password. It
# goes into /srv/collabora/.env with mode 600 and is never printed.
#
# This installs the editing engine only. It edits nothing on its own: another
# application, such as Nextcloud with the Collabora Online connector, hands it
# the documents. DOMAIN_HOST is the address that application will be pointed at,
# so it has to resolve for its server as well as for your browser. WOPI_HOST is
# the reverse: the address that application answers on, which this server will
# accept documents from. Leave WOPI_HOST unset and the alias group stays empty,
# which upstream treats as "all WOPI hosts will be denied" until you fill it in.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/collabora}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
WOPI_HOST="${WOPI_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. office.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; this install 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."

case "${WOPI_HOST}" in
	""|http://*|https://*) : ;;
	*) die "WOPI_HOST must include a scheme and a port, e.g. https://cloud.example.com:443" ;;
esac

# --- 2. Lay the files out ----------------------------------------------------
#
# No data directory and no volume: coolwsd builds its chroot jails and its cache
# inside the container, which the upstream source calls a state-less container.

sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
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: this value is typed into a browser login box, and hex
# has no characters that invite a typo. Read it later with
#   sudo grep password /srv/collabora/.env
#
# The four names are lowercase because coolwsd reads them from the environment
# exactly as written: username and password become the admin console
# credentials, server_name is what upstream documents for a server behind a
# reverse proxy, and aliasgroup1 is the WOPI host allowlist.

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		username=admin
		password=$(openssl rand -hex 24)
		server_name=${DOMAIN_HOST}:443
		aliasgroup1=${WOPI_HOST}
	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-collabora"
	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 8165 is not 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; 8165 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The image is about 470 MB compressed and the first start scans the fonts and
# dictionaries, so a few minutes of 502 here is normal rather than a fault.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/"
for _ in $(seq 1 30); do
	body="$(curl -sS "https://${DOMAIN_HOST}/" || true)"
	[ "$body" = "OK" ] && break
	sleep 10
done
[ "${body:-}" = "OK" ] || die "the root probe answered '${body:-nothing}'. Check: docker compose logs --tail 40 collabora"

discovery="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/hosting/discovery" || true)"
[ "$discovery" = "200" ] || die "/hosting/discovery answered ${discovery}, not 200"

curl -sS "https://${DOMAIN_HOST}/hosting/discovery" | grep -q 'wopi-discovery' \
	|| die "/hosting/discovery did not contain the expected wopi-discovery element"

# The admin console must refuse a request that carries no credentials. Upstream
# answers 401 there when the console is enabled and the caller is not logged in;
# a 200 would mean the password never reached the container and the console is
# open to anyone who finds this hostname.
console="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/browser/dist/admin/admin.html" || true)"
[ "$console" = "401" ] || die "the admin console returned ${console}, not 401. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------
#
# Two files rebuild this service completely, and the Caddy site block is
# archived from /etc/caddy so the copy has the real hostname in it rather than
# the <DOMAIN> template.

STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/collabora-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/collabora-config-${STAMP}.tar.gz" ] || die "the config archive is empty"

if [ -z "$WOPI_HOST" ]; then
	alias_note="No WOPI_HOST was given, so aliasgroup1 is empty and this server denies every application that asks it for a document. Put the address in $APP_DIR/.env, then run: docker compose up -d --force-recreate"
else
	alias_note="aliasgroup1 is ${WOPI_HOST}, so only that host may load documents through this server."
fi

cat <<-DONE

	Collabora Online is answering at https://${DOMAIN_HOST}/hosting/discovery

	  1. Nothing is editing a document yet. This is the editing engine, and
	     another application has to be pointed at it. In Nextcloud: install
	     the app named Collabora Online, open /settings/admin/richdocuments,
	     put https://${DOMAIN_HOST} in the Collabora Online server field, and
	     save.
	  2. ${alias_note}
	  3. The admin console is at
	       https://${DOMAIN_HOST}/browser/dist/admin/admin.html
	     Username admin. The password is in $APP_DIR/.env, mode 600. Read it
	     with
	       sudo grep password $APP_DIR/.env
	     and put it in your password manager. It was not printed here. An
	     unauthenticated request to that page was refused with 401, which is
	     the check that proves the password is in force.
	  4. First backup written to $APP_DIR/backups. It is on the same disk as
	     the config, which is not a backup. Copy it somewhere else tonight.

DONE
```

## Also evaluated

Ranked below Collabora Online for this swap. The prompts above install Collabora Online only.

- **ONLYOFFICE Docs** — The Word, Excel and PowerPoint half of an office subscription, as a server your own file app hands documents to. The OOXML-native sibling one page over, and the safer engine when docx, xlsx and pptx round-tripping with Microsoft Office users is the daily reality, because it treats those as its own formats rather than importing and exporting them. It ranks second here only because a Workspace reader's documents usually begin life in Google's editors rather than in Word, which is exactly the ground where Collabora's filters are level and its ODF story is better. Same shape otherwise: an editing engine with no storage, needing a file application in front of it.

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