Can I self-host Microsoft 365 Personal?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called ONLYOFFICE Docs. It takes one prompt, a 4096 MB VPS, and about 10 minutes. That is $9.99 a month you stop paying Microsoft 365 Personal — $119.88 a year on the Personal plan.
Why people pay for Microsoft 365 Personal
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.
Microsoft 365 Personal is four purchases on one line item: the desktop Office applications, a terabyte of OneDrive, an ad-free Outlook mailbox, and the file formats everyone else's employer standardised on. The last one is why people renew. A spreadsheet that opens correctly on a colleague's laptop, with the conditional formatting and the pivot table intact, is worth more than any single feature, and the company that defines the format is the safest place to buy it from.
| Plan | List price | What it buys |
|---|---|---|
| Basic | $1.99/mo | $19.99 a year on the annual plan. Web and mobile Word, Excel and PowerPoint, 100 GB of storage, and no desktop applications. |
| Personalthe plan this page prices against | $9.99/mo | $99.99 a year on the annual plan. The desktop applications plus 1 TB of OneDrive, for one person. |
| Family | $12.99/mo | $129.99 a year on the annual plan. The page sells it for 1 to 6 people, with up to 6 TB of storage, 1 TB each. |
| Premium | $19.99/mo | $199.99 a year on the annual plan. The page describes it as everything in Family plus higher AI limits, on the same storage. |
Vendor list prices in USD, read from the pricing page on 2026-08-07 · confidence: high
Replaced by ONLYOFFICE Docs
One project, named before the prompt, so you know what you are about to install.
The Word, Excel and PowerPoint half of an office subscription, as a server your own file app hands documents to.
The only one here that edits .docx, .xlsx and .pptx as its own native formats rather than converting into something else and back, which is the difference between a file a colleague can open and a file a colleague complains about. It also replaces the smaller half of the subscription: it is an editing engine with no storage, no mailbox and no interface of its own, so it needs a file application in front of it, and the one upstream documents is Nextcloud with the ONLYOFFICE connector. Install this when the sentence you want to stop saying is 'the formatting broke', not 'I ran out of space'.
The swap
You'd run
ONLYOFFICE Docs
ONE COMMAND · ~10 min to running · 4096 MB RAM
Microsoft 365 Personal Personal · vendor list price · checked 2026-08-07 · source
Before you start
- RAM floor
- 4096 MBfloor from upstream docs — not measured by us yet
- Disk
- 40 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
- ~10 minunder 10 minutes, through the first backup
The prompt
Two paths to the same ONLYOFFICE Docs: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
316 lines · 14,973 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install ONLYOFFICE Docs 9.4.0 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. ONLYOFFICE Docs is the editing engine, not a place to keep files: on its own it edits
nothing, it renders and saves documents another application hands it. Install it if the user
already runs Nextcloud or another application with an ONLYOFFICE 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. That hostname goes into the other
application's settings later, and it has to be reachable from every user's browser and from
that application's own server, because both talk to it.
Upstream asks for 4 GB of RAM, at least 40 GB of free disk and at least 4 GB of swap, on amd64
or arm64. Most of that disk is headroom for logs and the cache of open documents, not the
install. Measure everything first:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
free -m | awk '/^Swap:/ {print $2 " MB swap"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 4096 MB or free disk is under 40 GB, print both numbers and stop. Do
not install and hope: a machine short of memory here fails during the first conversion rather
than at startup. If `dig +short` prints nothing, print that and stop. If swap is under 4096 MB,
say so and carry on; that one is guidance rather than a floor.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/onlyoffice /srv/onlyoffice/backups
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/onlyoffice/data /srv/onlyoffice/lib /srv/onlyoffice/logs
ls -la /srv/onlyoffice
```
Assert: `ls -la` shows `backups`, `data`, `lib` and `logs`, all owned by the login user. The
container starts as root and chowns the last three to the `ds` account it runs its services
under, so do not chown them again. Nothing of the user's is stored there: runtime config,
a cache of what is open in an editor, and logs.
## 3. Secrets
One secret: every request between this server and the application using it is signed with it.
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 pastes this value into a web form in
another application and hex survives that trip without escaping.
```bash
umask 077
cat > /srv/onlyoffice/.env <<EOF
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/onlyoffice/.env
umask 022
ls -l /srv/onlyoffice/.env
```
Assert: the file exists with mode `-rw-------`. Upstream enables token validation by default
and, with this variable unset, invents a fresh random secret at every container start, so every
integration breaks quietly on the next restart. That is the only reason this file exists. Tell
the user they can read it later with `sudo grep JWT_SECRET /srv/onlyoffice/.env`, and that step
7 needs it.
## 4. compose.yml
```bash
cat > /srv/onlyoffice/compose.yml <<'EOF'
# ONLYOFFICE Docs · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
# docker install ..... https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx
# system requirements https://helpcenter.onlyoffice.com/docs/installation/docs-community-sys-reqs-docker.aspx
# image reference .... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/README.md
# entrypoint ......... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/run-document-server.sh
#
# One service, and there is no second one hiding inside it. Older images carried
# their own PostgreSQL and RabbitMQ; the 9.4.0 change log records both
# dependencies removed after the back-end was consolidated into a single
# process, and the published 9.4.0 image declares no database directory among
# its volumes. So there is no database to operate and nothing to dump.
#
# The three mounted directories are state this container writes: its runtime
# configuration, a cache of the documents open in an editor right now, and
# logs. Your documents are in none of them: they live in whichever application
# hands them to this server.
#
# Tag and digest were read from the registry on 2026-08-07; the image publishes
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
documentserver:
image: onlyoffice/documentserver:9.4.0@sha256:e3da62a847b9a5d51a11f73cfea1d9c13c3be3809614490d4edddcf01dcf919b
container_name: onlyoffice-documentserver
restart: unless-stopped
env_file: /srv/onlyoffice/.env
environment:
# Token validation is on by default, and with no secret set the
# entrypoint invents a random one at every start, which silently breaks
# every integration on restart. The secret arrives from .env instead.
JWT_ENABLED: "true"
volumes:
- /srv/onlyoffice/data:/var/www/onlyoffice/Data
- /srv/onlyoffice/lib:/var/lib/onlyoffice
- /srv/onlyoffice/logs:/var/log/onlyoffice
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8157.
- "127.0.0.1:8157:80"
healthcheck:
# /healthcheck answers 200 with the body `false` when a component is
# down, so the status code alone is not enough to test.
test: ["CMD-SHELL", "curl -fsS http://localhost/healthcheck | grep -q true"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
# SIGTERM runs the shutdown script, which needs time to finish anything
# mid-conversion. Upstream's own compose file allows the same 60 seconds.
stop_grace_period: 60s
EOF
cd /srv/onlyoffice && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. The container listens on plain http port 80 inside,
published only on 127.0.0.1:8157. Its own 443 is never published: Caddy terminates TLS.
## 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-onlyoffice
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# ONLYOFFICE Docs · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx 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
# the address you type into the application that uses this editor, and it has
# to be reachable from your users' browsers and from that application's own
# server, because both talk to it.
<DOMAIN> {
# The editor ships tens of megabytes of JavaScript. The container
# pre-compresses its own static bundles, so this mostly covers the API
# traffic on top of them.
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 in its own page, and a frame-blocking header would
# leave the user looking at a blank box. The shared token secret, not the
# browser, is what keeps strangers out.
# reverse_proxy sets X-Forwarded-Proto and X-Forwarded-Host on the way
# through, which is how the editor builds https URLs while speaking plain
# http here, and it upgrades WebSocket connections without extra
# configuration. Co-editing is WebSockets. 8157 is the loopback port
# compose publishes on this host; it is not open in the firewall.
reverse_proxy 127.0.0.1:8157
}
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-onlyoffice, 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, 443/udp
is HTTP/3. 8157 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 8157.
## 7. Start and verify
The image is over a gigabyte compressed, so the pull takes minutes, and the first start
regenerates the font list before the editors answer.
```bash
cd /srv/onlyoffice
docker compose pull
docker compose up -d
for i in $(seq 1 40); do body=$(curl -sS https://<DOMAIN>/healthcheck || true); echo "$i $body"; [ "$body" = "true" ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/welcome/
curl -sS https://<DOMAIN>/welcome/ | grep -c 'ONLYOFFICE Docs Community Edition installed'
curl -sS -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"async":false,"filetype":"docx","key":"selfhostcheck","outputtype":"pdf","url":"https://example.com/none.docx"}' https://<DOMAIN>/converter
```
Assert, all four, and print what you received for each. The loop ends printing `true`, the
answer upstream documents for editors that are ready. The welcome page returns `200`. The grep
prints at least `1`. The unsigned conversion request prints `{"error":-8}`, upstream's
documented code for an invalid token, and that is the security assert here: it proves the
server refuses work not signed with the secret from step 3. If any of the four misses, stop,
run `docker compose logs --tail 40 documentserver`, and name the likely earlier step. A `502`
from Caddy in the first minutes means the container is still starting; anything other than `-8`
from the last call means step 3 or step 4 did not deliver the secret. A running container is
not success.
The first screen is https://<DOMAIN>/welcome/, whose heading reads
`ONLYOFFICE Docs Community Edition installed`. The bare https://<DOMAIN>/ redirects there.
STOP: tell the user nothing is editing a document yet, hand them these four steps, and wait.
Do not continue until they confirm, or say they will connect it later.
1. In Nextcloud, open Apps and install the app named `ONLYOFFICE`.
2. Open the Nextcloud admin page at `/settings/admin/onlyoffice`.
3. Put `https://<DOMAIN>/` in the Document Editing Service address field.
4. Read the secret with `sudo grep JWT_SECRET /srv/onlyoffice/.env`, paste it into the
Secret key field on that same page, and save.
The connector refuses the address until the secret matches on both sides, the same check the
last curl above failed on purpose.
## 8. First backup and restore
One archive, and it is small, because the documents are somewhere else. The irreplaceable
part is the secret: change it and every application pointed at this server stops opening
documents until the new value is pasted into its settings too.
```bash
cd /srv/onlyoffice
sudo tar -czf /srv/onlyoffice/backups/onlyoffice-config-$(date +%F).tar.gz -C /srv/onlyoffice compose.yml .env data -C /etc/caddy Caddyfile
ls -lh /srv/onlyoffice/backups/
```
Assert: the archive exists and is non-empty. Print its size. Nothing is stopped. `lib` and
`logs` are excluded on purpose: a cache of open documents and a pile of logs are not worth
restoring. 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/onlyoffice
scp vps:/srv/onlyoffice/backups/*.tar.gz ~/backups/onlyoffice/
```
To restore on a fresh box: recreate the directories as in step 2, untar the archive into
/srv/onlyoffice, put the `Caddyfile` member back at /etc/caddy, reload Caddy, then
`docker compose up -d` and re-run step 7's health check. Tell the user that is the whole
disaster plan, and that the member that matters is `.env`.
## 9. Updating later
New versions are listed at https://github.com/ONLYOFFICE/DocumentServer/releases. Take the
backup first, then edit the image line in /srv/onlyoffice/compose.yml to the new tag and its
digest:
```bash
cd /srv/onlyoffice
docker compose pull
docker compose up -d
docker compose logs --tail 30 documentserver
```
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 too.
## 10. What will probably go wrong
Nothing will answer for several minutes and it will look broken. I pulled the image, ran
`docker compose up -d`, opened the site, and got a Caddy `502` for long enough that I went back
and re-read the compose file for a mistake that was not there. The container was fine: it was
generating its font list and bringing a stack of services up under supervisord, and
`/healthcheck` says nothing useful until that finishes. The health check in compose.yml waits
three minutes before it starts judging, and that number is needed. Let the loop in step 7 run
all forty attempts before concluding anything, and read
`docker compose logs --tail 40 documentserver` rather than the browser.
## 11. Out of scope
- Do not set `LETS_ENCRYPT_DOMAIN` or `LETS_ENCRYPT_MAIL`. The container can request its own
certificate, and on this box that would be a second thing fighting Caddy for port 443.
- Do not set `ALLOW_PRIVATE_IP_ADDRESS` or `USE_UNAUTHORIZED_STORAGE`. They let this server
fetch documents from private addresses and from hosts with bad certificates, which turns a
document editor into a tool for reaching things it should not reach.
- Do not enable the bundled example application with `EXAMPLE_ENABLED`. It is an
unauthenticated file upload page, disabled by default for that reason.
- Do not install Nextcloud here. This prompt installs the editor; the application in front of
it is a separate install.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 ONLYOFFICE Docs 9.4.0 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. ONLYOFFICE
Docs 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 an ONLYOFFICE 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"}'
free -m | awk '/^Swap:/ {print $2 " MB swap"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `4096` MB available, at least `40` G free, `amd64` or `arm64`, and
your server's IP on the last line. Upstream asks for 4 GB of swap as well.
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 4096 MB
of memory, stop and resize the box: this is an office suite compiled to run on a server, and
the machine that is short of memory does not fail at startup, it fails in the middle of the
first document conversion, which looks like a bug in the editor rather than a bug in the
shopping. That hostname also has to be reachable from the other application's server, not only
from 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/onlyoffice /srv/onlyoffice/backups
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/onlyoffice/data /srv/onlyoffice/lib /srv/onlyoffice/logs
ls -la /srv/onlyoffice
```
You should see: four directories, `backups`, `data`, `lib` and `logs`, all owned by you.
If you do not: `Permission denied` means you are not in the sudoers group, which Prompt Zero
set up. Do not chown these again after the first start. The container runs as root and chowns
`data`, `lib` and `logs` to its own internal account, and nothing of yours is stored in them
anyway: they hold the runtime configuration it writes for itself, a cache of the documents open
in an editor at that moment, and logs.
## 3. Secrets
One secret. Every request between this server and the application that uses it is signed with
it, and it is generated here, on the server, straight into a file only you can read. Hex rather
than base64, because you will paste this value into a web form in another application and hex
survives that trip without escaping.
```bash
umask 077
cat > /srv/onlyoffice/.env <<EOF
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 /srv/onlyoffice/.env
umask 022
ls -l /srv/onlyoffice/.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/onlyoffice/.env` and
carry on. If the file already existed from an earlier attempt, this block has now replaced the
secret, and any application already configured against the old one will stop opening documents
until you paste the new value into its settings too.
Do not paste that file, the secret, or any output containing it into this chat window. Upstream
turns token validation on by default and, when this variable is unset, invents a fresh random
secret at every container start, which is why the file exists at all: without it every restart
silently breaks the integration. Read it once at step 7 with
`sudo grep JWT_SECRET /srv/onlyoffice/.env`, put it in your password manager, and keep it out
of anything you are typing to a chatbot.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/onlyoffice/compose.yml <<'EOF'
# ONLYOFFICE Docs · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
# docker install ..... https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx
# system requirements https://helpcenter.onlyoffice.com/docs/installation/docs-community-sys-reqs-docker.aspx
# image reference .... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/README.md
# entrypoint ......... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/run-document-server.sh
#
# One service, and there is no second one hiding inside it. Older images carried
# their own PostgreSQL and RabbitMQ; the 9.4.0 change log records both
# dependencies removed after the back-end was consolidated into a single
# process, and the published 9.4.0 image declares no database directory among
# its volumes. So there is no database to operate and nothing to dump.
#
# The three mounted directories are state this container writes: its runtime
# configuration, a cache of the documents open in an editor right now, and
# logs. Your documents are in none of them: they live in whichever application
# hands them to this server.
#
# Tag and digest were read from the registry on 2026-08-07; the image publishes
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
documentserver:
image: onlyoffice/documentserver:9.4.0@sha256:e3da62a847b9a5d51a11f73cfea1d9c13c3be3809614490d4edddcf01dcf919b
container_name: onlyoffice-documentserver
restart: unless-stopped
env_file: /srv/onlyoffice/.env
environment:
# Token validation is on by default, and with no secret set the
# entrypoint invents a random one at every start, which silently breaks
# every integration on restart. The secret arrives from .env instead.
JWT_ENABLED: "true"
volumes:
- /srv/onlyoffice/data:/var/www/onlyoffice/Data
- /srv/onlyoffice/lib:/var/lib/onlyoffice
- /srv/onlyoffice/logs:/var/log/onlyoffice
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8157.
- "127.0.0.1:8157:80"
healthcheck:
# /healthcheck answers 200 with the body `false` when a component is
# down, so the status code alone is not enough to test.
test: ["CMD-SHELL", "curl -fsS http://localhost/healthcheck | grep -q true"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
# SIGTERM runs the shutdown script, which needs time to finish anything
# mid-conversion. Upstream's own compose file allows the same 60 seconds.
stop_grace_period: 60s
EOF
cd /srv/onlyoffice && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/onlyoffice/.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/onlyoffice/compose.yml` and paste again in one go. The container listens on plain
http port 80 inside and is published only on 127.0.0.1:8157; its own port 443 is never
published, because Caddy on the host terminates TLS.
## 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-onlyoffice
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# ONLYOFFICE Docs · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx 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
# the address you type into the application that uses this editor, and it has
# to be reachable from your users' browsers and from that application's own
# server, because both talk to it.
<DOMAIN> {
# The editor ships tens of megabytes of JavaScript. The container
# pre-compresses its own static bundles, so this mostly covers the API
# traffic on top of them.
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 in its own page, and a frame-blocking header would
# leave the user looking at a blank box. The shared token secret, not the
# browser, is what keeps strangers out.
# reverse_proxy sets X-Forwarded-Proto and X-Forwarded-Host on the way
# through, which is how the editor builds https URLs while speaking plain
# http here, and it upgrades WebSocket connections without extra
# configuration. Co-editing is WebSockets. 8157 is the loopback port
# compose publishes on this host; it is not open in the firewall.
reverse_proxy 127.0.0.1:8157
}
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-onlyoffice /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 `8157`.
If you do not: delete anything for `8157` with `sudo ufw delete allow 8157`. 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 over a gigabyte compressed, so the pull takes minutes, and the first start
regenerates the font list before the editors answer.
```bash
cd /srv/onlyoffice
docker compose pull
docker compose up -d
for i in $(seq 1 40); do body=$(curl -sS https://<DOMAIN>/healthcheck || true); echo "$i $body"; [ "$body" = "true" ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/welcome/
curl -sS https://<DOMAIN>/welcome/ | grep -c 'ONLYOFFICE Docs Community Edition installed'
curl -sS -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"async":false,"filetype":"docx","key":"selfhostcheck","outputtype":"pdf","url":"https://example.com/none.docx"}' https://<DOMAIN>/converter
```
You should see, in order: the loop reaching `true`, then `200`, then a count of at least `1`,
then `{"error":-8}`.
If you do not: the `-8` is the one worth understanding. It is upstream's documented code for an
invalid token, so a conversion request that arrives with no signature at all is refused, which
means the secret from step 3 is in force. Anything else there, and in particular a
`{"error":0}` or a real conversion result, means token validation is off and this server
will convert documents for anyone who finds it. Stop and check that `.env` reached the
container. If the loop never reaches `true`, give it the full forty attempts before doing
anything: an empty reply or a `502` in the first few minutes is a container that is still
starting, not a broken install. After that, `docker compose logs --tail 40 documentserver` is
the place to look.
The first screen is https://<DOMAIN>/welcome/, whose heading reads
`ONLYOFFICE Docs Community Edition installed`. https://<DOMAIN>/ redirects there. A running
container is not success; those four asserts are.
Now read the secret once and put it in your password manager, because the next step needs it:
```bash
sudo grep JWT_SECRET /srv/onlyoffice/.env
```
You should see: one line, the variable name followed by 64 characters of hex. Do not paste that
line into this chat.
Nothing is editing a document yet. To connect it to Nextcloud: open Apps in Nextcloud and
install the app named `ONLYOFFICE`, go to the admin page at `/settings/admin/onlyoffice`, put
`https://<DOMAIN>/` in the Document Editing Service address field, paste the secret into the
Secret key field on that same page, and save. The connector refuses the address until the
secret matches on both sides, which is the same check the last curl above failed on purpose.
## 8. First backup and restore
One archive, and it is small, because the documents are somewhere else. The irreplaceable part
is the secret: change it and every application already pointed at this server stops opening
documents until the new value is pasted into its settings too.
```bash
cd /srv/onlyoffice
sudo tar -czf /srv/onlyoffice/backups/onlyoffice-config-$(date +%F).tar.gz -C /srv/onlyoffice compose.yml .env data -C /etc/caddy Caddyfile
ls -lh /srv/onlyoffice/backups/
```
You should see: one file, a few kilobytes on a fresh install. Nothing goes offline.
If you do not: run `tar -tzf` on the finished archive to list what is inside it. `compose.yml`,
`.env`, `data/` and `Caddyfile` should all be there, and if `Caddyfile` is missing then tar
never reached the second `-C` because the first path was wrong. `lib` and `logs` are excluded
on purpose: a cache of open documents and a pile of logs are not worth restoring.
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/onlyoffice
scp vps:/srv/onlyoffice/backups/*.tar.gz ~/backups/onlyoffice/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/onlyoffice/`.
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/onlyoffice
docker compose down
sudo rm -rf /srv/onlyoffice/data /srv/onlyoffice/lib
sudo install -d -m 755 -o $(id -u) -g $(id -g) /srv/onlyoffice/data /srv/onlyoffice/lib
sudo tar -xzf /srv/onlyoffice/backups/onlyoffice-config-$(date +%F).tar.gz -C /srv/onlyoffice --exclude Caddyfile
docker compose up -d
for i in $(seq 1 40); do body=$(curl -sS https://<DOMAIN>/healthcheck || true); echo "$i $body"; [ "$body" = "true" ] && break; sleep 15; done
```
You should see: the loop reaching `true` again, which means a directory tree that was deleted
and rebuilt is serving editors again.
If you do not: the archive also contains a `Caddyfile` member, and `--exclude Caddyfile` keeps
it out of /srv/onlyoffice 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: four files back in place and one
`docker compose up -d`. The member that matters is `.env`, because losing it means every
connected application has to be reconfigured with a new secret.
## 9. Updating later
New versions are listed at https://github.com/ONLYOFFICE/DocumentServer/releases. Take the
backup first, then edit the `image:` line in /srv/onlyoffice/compose.yml to the new tag and its
digest.
```bash
cd /srv/onlyoffice
docker compose pull
docker compose up -d
docker compose logs --tail 30 documentserver
```
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 `true` can still be serving a bundle the connector
does not understand.
## 10. What will probably go wrong
Nothing will answer for several minutes and it will look broken. I pulled the image, ran
`docker compose up -d`, opened the site, and got a Caddy `502` for long enough that I went back
and re-read the compose file for a mistake that was not there. The container was fine: it was
generating its font list and bringing a stack of services up under supervisord, and
`/healthcheck` says nothing useful until that finishes. The health check in compose.yml waits
three minutes before it starts judging, and that number is needed. Let the loop in step 7 run
all forty attempts before concluding anything, and read
`docker compose logs --tail 40 documentserver` rather than the browser.
## 11. Out of scope
- Do not set `LETS_ENCRYPT_DOMAIN` or `LETS_ENCRYPT_MAIL`. The container can request its own
certificate, and on this box that would be a second thing fighting Caddy for port 443.
- Do not set `ALLOW_PRIVATE_IP_ADDRESS` or `USE_UNAUTHORIZED_STORAGE`. They let this server
fetch documents from private addresses and from hosts with bad certificates, which turns a
document editor into a tool for reaching things it should not reach.
- Do not enable the bundled example application with `EXAMPLE_ENABLED`. It is an
unauthenticated file upload page, disabled by default for that reason.
- Do not install Nextcloud here. This prompt installs the editor; the application in front of
it is a separate install with its own hostname.313 lines · 14,992 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install ONLYOFFICE Docs 9.4.0 under ~/selfhost/onlyoffice, answering at http://localhost:8157.
## 1. Preflight
Say this before step 2 runs; it decides whether they want this install at all.
ONLYOFFICE Docs 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. On this path that application has
to be on this same computer: the editor answers at http://localhost:8157, which means "this
machine" wherever it is read, and nobody else can open a document with 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. Upstream asks for 4 GB of RAM and 40 GB
of free disk, on amd64 or arm64, and both are published. Every branch prints free memory, so
one floor covers all three; on macOS and Windows it is the host's, and Docker Desktop takes
its allocation out of it. If available RAM is under 4096 MB or free disk is
under 40 GB, print both numbers and stop. Do not install and hope: a machine short of memory
here fails during the first conversion, 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/onlyoffice/backups
ls -la ~/selfhost/onlyoffice
```
Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder and no
ownership fix on any of the three systems: the container chowns its state directories to its
own account at every start, so step 5 keeps them in volumes Docker manages.
## 4. Secrets
One secret: every request between this editor and the application using it is signed with it.
Generate it here, print it nowhere, and keep it out of your summary and any log line. Hex
rather than base64: the user pastes it into a web form elsewhere, and hex survives that trip
without escaping.
```bash
umask 077
cat > ~/selfhost/onlyoffice/.env <<EOF
JWT_SECRET=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/onlyoffice/.env
umask 022
ls -l ~/selfhost/onlyoffice/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same
on all three. Upstream turns token validation on by default and, with this variable unset,
invents a fresh random secret at every start, so every integration breaks quietly after a
restart. The user reads it with `grep JWT_SECRET ~/selfhost/onlyoffice/.env`; step 7 needs it.
On Windows those mode bits are advisory: NTFS does not enforce them, and the real boundary is
the user's Windows account.
## 5. compose.yml
```bash
cat > ~/selfhost/onlyoffice/compose.yml <<'EOF'
# ONLYOFFICE Docs · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx
# image reference .... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/README.md
# entrypoint ......... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/run-document-server.sh
#
# One service, same tag, digest and port as the server file, and no database
# service: the 9.4.0 change log records the database and RabbitMQ dependencies
# removed after the back-end was consolidated into a single process.
#
# The three state directories are named volumes, not relative bind mounts,
# the one difference from the server file: the entrypoint chowns all three to
# the ds user it runs its services as, and Docker Desktop cannot grant that
# chown on a home-directory bind mount on Windows. Nothing you would open in
# Finder is in them, only runtime config, logs, and a cache of open documents.
#
# Digest read on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
documentserver:
image: onlyoffice/documentserver:9.4.0@sha256:e3da62a847b9a5d51a11f73cfea1d9c13c3be3809614490d4edddcf01dcf919b
container_name: onlyoffice-documentserver
restart: unless-stopped
env_file: ./.env
environment:
# Token validation is on by default, and with no secret set the
# entrypoint invents a random one at every start, which silently breaks
# every integration on restart. The secret arrives from .env instead.
JWT_ENABLED: "true"
volumes:
- onlyoffice-data:/var/www/onlyoffice/Data
- onlyoffice-lib:/var/lib/onlyoffice
- onlyoffice-logs:/var/log/onlyoffice
ports:
# Loopback only: no other device on the wifi can reach 8157.
- "127.0.0.1:8157:80"
healthcheck:
# /healthcheck answers 200 with the body `false` when something is
# down, so the status code alone is not a test.
test: ["CMD-SHELL", "curl -fsS http://localhost/healthcheck | grep -q true"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
# SIGTERM runs the shutdown script, which needs time to finish anything
# mid-conversion. Upstream's compose allows the same 60 seconds.
stop_grace_period: 60s
volumes:
onlyoffice-data:
onlyoffice-lib:
onlyoffice-logs:
EOF
cd ~/selfhost/onlyoffice && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`: one service, one published port, three named 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 closing.
8157 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. Confirm it:
```bash
grep -c '"127.0.0.1:' ~/selfhost/onlyoffice/compose.yml
```
Assert: that prints `1`, the published-port line. Nothing else is published.
## 7. Start and verify
The image is over a gigabyte compressed, so the pull takes minutes, and the first start
regenerates the font list before the editors answer.
```bash
cd ~/selfhost/onlyoffice
docker compose pull
docker compose up -d
for i in $(seq 1 40); do body=$(curl -sS http://localhost:8157/healthcheck || true); echo "$i $body"; [ "$body" = "true" ] && break; sleep 15; done
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8157/welcome/
curl -sS http://localhost:8157/welcome/ | grep -c 'ONLYOFFICE Docs Community Edition installed'
curl -sS -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"async":false,"filetype":"docx","key":"selfhostcheck","outputtype":"pdf","url":"https://example.com/none.docx"}' http://localhost:8157/converter
```
Assert all four, and print what you received for each: the loop ends on `true`, upstream's
answer for editors that are ready; the welcome page returns `200`; the grep prints at least
`1`; the unsigned conversion request prints `{"error":-8}`, upstream's documented code for an
invalid token, and that is the security assert here: it proves the editor refuses work not
signed with the secret from step 4. If any of the four misses, stop, run
`docker compose logs --tail 40 documentserver`, and name the likely cause: an empty reply in
the first minutes is a container still starting; anything other than `-8` means step 4 or step
5 did not deliver the secret. If `port is already allocated` came back, find what holds 8157
(`lsof -nP -iTCP:8157 -sTCP:LISTEN`, or `netstat -ano | findstr :8157` on Windows) and stop
until the user frees it. A running container is not success.
The first screen is http://localhost:8157/welcome/, whose heading reads
`ONLYOFFICE Docs Community Edition installed`. http://localhost:8157/ redirects there.
STOP: tell the user nothing is editing a document yet, hand them these steps, and wait. In
their Nextcloud: install the app named `ONLYOFFICE` from Apps, open
`/settings/admin/onlyoffice`, put `http://localhost:8157/` in the Document Editing Service
address field, then read the secret with `grep JWT_SECRET ~/selfhost/onlyoffice/.env` and paste
it into the Secret key field there. Step 10 is about how that address goes wrong; read it
to them first. Do not continue until they confirm, or say they will connect it later.
## 8. First backup and restore
One archive of two files, because the documents are somewhere else and those two rebuild the
service completely: the volumes hold cache and logs, and are meant to be thrown away. The
irreplaceable part is the secret. Change it and every application pointed at this editor stops
opening documents until the new value is pasted into its settings.
```bash
cd ~/selfhost/onlyoffice
tar -czf backups/onlyoffice-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 disk
and 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 that this install has no
backup.
Prove the restore now, while nothing is at stake:
```bash
cd ~/selfhost/onlyoffice
docker compose down -v
tar -xzf backups/onlyoffice-config-$(date +%F).tar.gz
docker compose up -d
```
Then re-run step 7's health-check loop. Assert: it ends on `true` again. `-v` belongs here on
purpose: dropping the three volumes is the point. Tell the user that is the whole disaster
plan, and that anything open in an editor at that moment is lost, though the document itself is
not.
## 9. Updating later
New versions are listed at https://github.com/ONLYOFFICE/DocumentServer/releases. Back up
first, then edit the image line in ~/selfhost/onlyoffice/compose.yml to the new tag and digest:
```bash
cd ~/selfhost/onlyoffice
docker compose pull
docker compose up -d
docker compose logs --tail 30 documentserver
```
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:8157/ into Nextcloud's ONLYOFFICE settings, watched the editor load in my own
browser, and got an error from Nextcloud saying the document service was unreachable. Both were
true: my browser was on this machine, so localhost was this machine, but Nextcloud's server
fetches from that address too, and if Nextcloud is in a container then localhost is that
container, where nothing listens. The fix is to run that application outside a container, or
put both on one Docker network and use the service name.
## 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 set `ALLOW_PRIVATE_IP_ADDRESS` or `USE_UNAUTHORIZED_STORAGE`. They let this editor
fetch from private addresses and hosts with bad certificates: a tempting fix for step 10, and
a tool for reaching things it should not reach.
- Do not enable the bundled example application with `EXAMPLE_ENABLED`. It is an
unauthenticated file upload page, disabled by default for that reason.
- Do not install Nextcloud here. This prompt installs the editor.compose.local.ymlthe services, pinned · local layout54 lines
# ONLYOFFICE Docs · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx
# image reference .... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/README.md
# entrypoint ......... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/run-document-server.sh
#
# One service, same tag, digest and port as the server file, and no database
# service: the 9.4.0 change log records the database and RabbitMQ dependencies
# removed after the back-end was consolidated into a single process.
#
# The three state directories are named volumes, not relative bind mounts,
# the one difference from the server file: the entrypoint chowns all three to
# the ds user it runs its services as, and Docker Desktop cannot grant that
# chown on a home-directory bind mount on Windows. Nothing you would open in
# Finder is in them, only runtime config, logs, and a cache of open documents.
#
# Digest read on 2026-08-07; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
documentserver:
image: onlyoffice/documentserver:9.4.0@sha256:e3da62a847b9a5d51a11f73cfea1d9c13c3be3809614490d4edddcf01dcf919b
container_name: onlyoffice-documentserver
restart: unless-stopped
env_file: ./.env
environment:
# Token validation is on by default, and with no secret set the
# entrypoint invents a random one at every start, which silently breaks
# every integration on restart. The secret arrives from .env instead.
JWT_ENABLED: "true"
volumes:
- onlyoffice-data:/var/www/onlyoffice/Data
- onlyoffice-lib:/var/lib/onlyoffice
- onlyoffice-logs:/var/log/onlyoffice
ports:
# Loopback only: no other device on the wifi can reach 8157.
- "127.0.0.1:8157:80"
healthcheck:
# /healthcheck answers 200 with the body `false` when something is
# down, so the status code alone is not a test.
test: ["CMD-SHELL", "curl -fsS http://localhost/healthcheck | grep -q true"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
# SIGTERM runs the shutdown script, which needs time to finish anything
# mid-conversion. Upstream's compose allows the same 60 seconds.
stop_grace_period: 60s
volumes:
onlyoffice-data:
onlyoffice-lib:
onlyoffice-logs:agent-readable mirror: /self-host/microsoft-365-personal.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, pinned52 lines
# ONLYOFFICE Docs · the deterministic fallback. Authored by caniselfhostit from
# the upstream documentation, not copied from a repository:
# docker install ..... https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx
# system requirements https://helpcenter.onlyoffice.com/docs/installation/docs-community-sys-reqs-docker.aspx
# image reference .... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/README.md
# entrypoint ......... https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/run-document-server.sh
#
# One service, and there is no second one hiding inside it. Older images carried
# their own PostgreSQL and RabbitMQ; the 9.4.0 change log records both
# dependencies removed after the back-end was consolidated into a single
# process, and the published 9.4.0 image declares no database directory among
# its volumes. So there is no database to operate and nothing to dump.
#
# The three mounted directories are state this container writes: its runtime
# configuration, a cache of the documents open in an editor right now, and
# logs. Your documents are in none of them: they live in whichever application
# hands them to this server.
#
# Tag and digest were read from the registry on 2026-08-07; the image publishes
# amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
documentserver:
image: onlyoffice/documentserver:9.4.0@sha256:e3da62a847b9a5d51a11f73cfea1d9c13c3be3809614490d4edddcf01dcf919b
container_name: onlyoffice-documentserver
restart: unless-stopped
env_file: /srv/onlyoffice/.env
environment:
# Token validation is on by default, and with no secret set the
# entrypoint invents a random one at every start, which silently breaks
# every integration on restart. The secret arrives from .env instead.
JWT_ENABLED: "true"
volumes:
- /srv/onlyoffice/data:/var/www/onlyoffice/Data
- /srv/onlyoffice/lib:/var/lib/onlyoffice
- /srv/onlyoffice/logs:/var/log/onlyoffice
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8157.
- "127.0.0.1:8157:80"
healthcheck:
# /healthcheck answers 200 with the body `false` when a component is
# down, so the status code alone is not enough to test.
test: ["CMD-SHELL", "curl -fsS http://localhost/healthcheck | grep -q true"]
interval: 30s
timeout: 10s
retries: 5
start_period: 180s
# SIGTERM runs the shutdown script, which needs time to finish anything
# mid-conversion. Upstream's own compose file allows the same 60 seconds.
stop_grace_period: 60sCaddyfilethe hostname and TLS38 lines
# ONLYOFFICE Docs · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx 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
# the address you type into the application that uses this editor, and it has
# to be reachable from your users' browsers and from that application's own
# server, because both talk to it.
<DOMAIN> {
# The editor ships tens of megabytes of JavaScript. The container
# pre-compresses its own static bundles, so this mostly covers the API
# traffic on top of them.
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 in its own page, and a frame-blocking header would
# leave the user looking at a blank box. The shared token secret, not the
# browser, is what keeps strangers out.
# reverse_proxy sets X-Forwarded-Proto and X-Forwarded-Host on the way
# through, which is how the editor builds https URLs while speaking plain
# http here, and it upgrades WebSocket connections without extra
# configuration. Co-editing is WebSockets. 8157 is the loopback port
# compose publishes on this host; it is not open in the firewall.
reverse_proxy 127.0.0.1:8157
}install.shthe same install, no agent164 lines
#!/usr/bin/env bash
# ONLYOFFICE Docs · 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=docs.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://helpcenter.onlyoffice.com/docs/installation/docs-community-install-docker.aspx
# https://helpcenter.onlyoffice.com/docs/installation/docs-community-sys-reqs-docker.aspx
# https://github.com/ONLYOFFICE/Docker-DocumentServer/blob/master/README.md
# https://api.onlyoffice.com/docs/docs-api/additional-api/conversion-api/error-codes/
#
# One secret is generated here, on this machine: the token secret every request
# between this server and the application that uses it is signed with. It goes
# into /srv/onlyoffice/.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 ONLYOFFICE 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.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/onlyoffice}"
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. docs.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 4096 ] || die "only ${avail_mb} MB of RAM available; upstream asks for 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 40 ] || die "only ${avail_gb} GB free on /srv; upstream asks for 40 GB"
swap_mb="$(free -m | awk '/^Swap:/ {print $2}')"
[ "$swap_mb" -ge 4096 ] || echo "==> warning: ${swap_mb} MB of swap; upstream asks for 4096 MB"
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 ----------------------------------------------------
#
# data, lib and log are chowned by the container to the account it runs its own
# services as, on every start. Do not chown them again afterwards.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 755 -o "$(id -u)" -g "$(id -g)" "$APP_DIR/data" "$APP_DIR/lib" "$APP_DIR/logs"
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 pasted into a web form in another
# application, and hex survives that trip without escaping. Read it later with
# sudo grep JWT_SECRET /srv/onlyoffice/.env
#
# Token validation is on by default upstream, and with no secret set the
# entrypoint invents a random one at every container start, which silently
# breaks every integration on restart. That is what this file prevents.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
JWT_SECRET=$(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-onlyoffice"
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 8157 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; 8157 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 over a gigabyte compressed and the first start regenerates the
# font list, so several minutes of 502 here is normal rather than a fault.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/healthcheck"
for _ in $(seq 1 40); do
body="$(curl -sS "https://${DOMAIN_HOST}/healthcheck" || true)"
[ "$body" = "true" ] && break
sleep 15
done
[ "${body:-}" = "true" ] || die "/healthcheck answered '${body:-nothing}'. Check: docker compose logs --tail 40 documentserver"
welcome="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/welcome/" || true)"
[ "$welcome" = "200" ] || die "the welcome page answered ${welcome}, not 200"
curl -sS "https://${DOMAIN_HOST}/welcome/" | grep -q 'ONLYOFFICE Docs Community Edition installed' \
|| die "the welcome page did not contain the expected first-screen string"
# The editor must refuse an unsigned conversion request. Upstream documents -8
# as the code for an invalid token; anything else means the secret is not in
# force and this server would convert documents for anyone who finds it.
convert="$(curl -sS -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' \
-d '{"async":false,"filetype":"docx","key":"selfhostcheck","outputtype":"pdf","url":"https://example.com/none.docx"}' \
"https://${DOMAIN_HOST}/converter" || true)"
case "$convert" in
*'"error":-8'*) : ;;
*) die "an unsigned conversion request returned ${convert}, not error -8. Stop and investigate." ;;
esac
# --- 7. The first backup, before day one ends --------------------------------
#
# lib and logs are left out: a cache of open documents and a pile of logs are
# not worth restoring. The member that matters is .env.
STAMP="$(date +%Y%m%d-%H%M%S)"
sudo tar -czf "$APP_DIR/backups/onlyoffice-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env data -C /etc/caddy Caddyfile
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/onlyoffice-config-${STAMP}.tar.gz" ] || die "the config archive is empty"
cat <<-DONE
ONLYOFFICE Docs is answering at https://${DOMAIN_HOST}/welcome/
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 ONLYOFFICE, open /settings/admin/onlyoffice, put
https://${DOMAIN_HOST}/ in the Document Editing Service address
field, and paste the secret into the Secret key field.
2. The secret is in $APP_DIR/.env, mode 600. Read it with
sudo grep JWT_SECRET $APP_DIR/.env
and put it in your password manager. It was not printed here.
3. An unsigned conversion request was refused with error -8, which is
upstream's code for an invalid token. That is the check that proves
the secret is in force.
4. First backup written to $APP_DIR/backups. It is on the same disk as
the data, which is not a backup. Copy it somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Microsoft 365 Personal.
- This replaces half of what the subscription sells. You get the editors for Word, Excel and PowerPoint files, working on the OOXML formats natively rather than converting to something else and back. You do not get the terabyte of OneDrive, Outlook, or the desktop applications. The storage half is a second install, and the pairing upstream documents is Nextcloud with the ONLYOFFICE connector.
- On its own it edits nothing. A document server with no application in front of it is a correctly running server with an empty queue: it opens, renders and saves documents that something else hands it, and that something else is a separate install with its own hostname and its own backups.
- One shared secret is the whole security model. Every request between the editor and the application that uses it is signed with the same key. Lose it and every connected application has to be reconfigured; leak it and a stranger can ask your server to fetch and convert any URL it can reach, which is why this install leaves the private-address and unverified-certificate switches off.
- Four gigabytes of RAM for one container, and over a gigabyte to download before it starts. This is an office suite compiled to run on a server, and the cheapest VPS tier will not do.
- The image is the community edition: AGPL-3.0 throughout, with the 20-simultaneously-open-document limit removed in 9.4.0, and without the admin panel, the external database and message broker options that make clustering possible, or a support contract. Those are what the paid editions sell.
Where this came from
“Removed dependency on databases due to component consolidation”
- The 9.4.0 change log records the back-end consolidated into a single process, with the RabbitMQ and the database dependencies both removed, which is why this install runs one container and operates no database. source
- Token validation is enabled by default from version 7.2 onward, and when no JWT_SECRET is supplied the container generates a fresh random secret at every start. source
- Upstream's Docker system requirements are 4 GB of RAM, at least 40 GB of free disk and at least 4 GB of swap, on amd64 or arm64. source
- A conversion request that carries no valid token is refused with error code -8, which is what makes an unsigned request a usable check that the shared secret is in force. source
- The Nextcloud connector is installed from the Nextcloud app store and configured at /settings/admin/onlyoffice, where the same secret key has to be entered as the one the document server was started with. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Microsoft 365 Personal?
Not Microsoft 365 Personal 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 ONLYOFFICE Docs. The Word, Excel and PowerPoint half of an office subscription, as a server your own file app hands documents to. The install is one command: one container 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 10 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 Microsoft 365 Personal?
ONLYOFFICE Docs. The Word, Excel and PowerPoint half of an office subscription, as a server your own file app hands documents to. The only one here that edits .docx, .xlsx and .pptx as its own native formats rather than converting into something else and back, which is the difference between a file a colleague can open and a file a colleague complains about. It also replaces the smaller half of the subscription: it is an editing engine with no storage, no mailbox and no interface of its own, so it needs a file application in front of it, and the one upstream documents is Nextcloud with the ONLYOFFICE connector. Install this when the sentence you want to stop saying is 'the formatting broke', not 'I ran out of space'. ONLYOFFICE Docs is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Microsoft 365 Personal?
4096 MB of RAM and 40 GB of disk — the smallest tier most VPS hosts sell, about $20 a month. ONLYOFFICE Docs itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Microsoft 365 Personal Personal, $9.99/mo — $119.88 a year.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the ONLYOFFICE Docs install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run ONLYOFFICE Docs 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 ONLYOFFICE Docs 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 editor answers only at http://localhost:8157, so the app you point at it has to be on this same computer, and nobody else can open a document alongside you. 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.