# Can I self-host Grammarly?

**YES** — it's called LanguageTool. ONE COMMAND setup · ~10 minutes to running · 2 GB RAM minimum · $12/mo you stop paying ($144/yr on the Pro plan).

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

## 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 LanguageTool 6.8 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS and a login box Caddy checks.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server. Every editor, plugin and add-on they point
here is configured with `https://<DOMAIN>/v2`, so that hostname goes into a settings box on
every device they write from.

LanguageTool is a Java service that loads dictionaries for every language it knows. It needs
2048 MB of RAM available and 5 GB free on /srv. The image publishes amd64 and arm64. Measure
all five first:

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

If available RAM is under 2048 MB or free disk is under 5 GB, print both numbers and stop. Do
not install and hope: the Java heap ceiling in step 4 is 1 GB, and the OOM killer arrives in
the middle of a check rather than at start-up. `caddy version` must print 2.8 or newer, which
is where the `basic_auth` directive step 5 uses was renamed from `basicauth`. If `dig +short`
prints nothing, print that and stop.

## 2. Layout

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

Assert: `ls -la` shows `backups` owned by the login user. There is no `data` directory and
there will not be one. LanguageTool reads its rules and dictionaries out of the image and
keeps nothing between requests: text arrives, is checked, is answered, and is dropped. Step 8
depends on that.

## 3. Secrets

One secret: the password on the login box in front of the API. Generate it here, print it
nowhere, keep it out of your summary and out of any log line. It carries more weight than its
size suggests, because the LanguageTool HTTP server has no accounts, no API key and no login
of its own, and the image starts it with `--public` and `--allow-origin '*'`. Whatever can
reach the container gets an answer.

```bash
umask 077
openssl rand -hex 24 > /srv/languagetool/api-password
printf 'machine <DOMAIN> login languagetool password %s\n' "$(cat /srv/languagetool/api-password)" > /srv/languagetool/.netrc
chmod 600 /srv/languagetool/api-password /srv/languagetool/.netrc
umask 022
ls -l /srv/languagetool/api-password /srv/languagetool/.netrc
```

Assert: both files exist with mode `-rw-------`. Hex rather than base64, because this value
gets typed into settings boxes on other machines and hex has nothing a keyboard layout can
ruin. The `.netrc` is the same credential in the form curl reads from a file, which is how
step 7 signs in without putting it in the process list. The username is `languagetool`.

## 4. compose.yml

```bash
cat > /srv/languagetool/compose.yml <<'EOF'
# LanguageTool · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   http server ........ https://dev.languagetool.org/http-server
#   upstream readme .... https://github.com/languagetool-org/languagetool/blob/v6.8/README.md
#   image readme ....... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/README.md
#   image dockerfile ... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/Dockerfile
#
# One service and no database. LanguageTool loads its rules and dictionaries out
# of the image and keeps nothing between requests: no volume, nothing to
# migrate, nothing on disk to lose.
#
# The LanguageTool project publishes no Docker image. Its README names three
# community-contributed Dockerfiles and this install uses one of them,
# Erikvl87/docker-languagetool, LGPL-2.1 like LanguageTool itself. That
# Dockerfile clones the upstream v6.8 tag and builds it with Maven, so the code
# is upstream's and the packaging is somebody else's. Tag and digest were read
# from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  languagetool:
    image: erikvl87/languagetool:6.8@sha256:ef8fa12cbd485166c9ceeb7139d76d56d07707a624da6bb1fc1fbb5411750527
    container_name: languagetool
    restart: unless-stopped
    environment:
      # The image's start script reads these two and defaults to 256m and 512m.
      # 512m runs out of room once several languages load, and the RAM floor in
      # the install accounts for the larger ceiling.
      Java_Xms: 512m
      Java_Xmx: 1g
      # Every langtool_* variable becomes one line in the server's
      # config.properties. This one caps a single request so one enormous paste
      # cannot hold the whole JVM. 40000 characters is a long document.
      langtool_maxTextLength: "40000"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8149. The
      # image starts the server with --public and --allow-origin '*', so it
      # answers whoever reaches it. Nothing else may.
      - "127.0.0.1:8149:8010"
    # The image ships a HEALTHCHECK that posts a sentence to /v2/check, so
    # `docker compose ps` reports healthy or unhealthy without help from here.
EOF
cd /srv/languagetool && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. There is no `env_file` line because no secret enters this
container: the image's start script copies every `langtool_` variable into a config file and
prints that file to the container log, where `docker compose logs` reads it.

## 5. Caddy and TLS

Two files. First the credential Caddy checks, a bcrypt hash of the password step 3 generated,
written where the caddy user can read it and nowhere else:

```bash
umask 077
caddy hash-password < /srv/languagetool/api-password > /srv/languagetool/auth.hash
printf 'basic_auth {\n\tlanguagetool %s\n}\n' "$(cat /srv/languagetool/auth.hash)" > /srv/languagetool/auth.conf
umask 022
sudo install -m 640 -o root -g caddy /srv/languagetool/auth.conf /etc/caddy/languagetool-auth.conf
rm -f /srv/languagetool/auth.hash /srv/languagetool/auth.conf
sudo grep -c basic_auth /etc/caddy/languagetool-auth.conf
```

Assert: that prints `1`. Reading the password from a file rather than as an argument keeps it
out of the process list.

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-languagetool
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LanguageTool · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://dev.languagetool.org/http-server,
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. The LanguageTool
# HTTP server has no accounts and no API key of its own, and the image starts it
# with --public and --allow-origin '*', so it answers whoever reaches it. This
# block is the only door on the install: Caddy checks one credential before a
# byte reaches the container. Needs Caddy 2.8, where basicauth became basic_auth.

<DOMAIN> {
	# JSON in, JSON out. A check response for a long document compresses well,
	# and there is no HTML here to frame or to sniff.
	encode zstd gzip

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

	# The credential is not in this file, because this file is published. The
	# install writes /etc/caddy/languagetool-auth.conf with one basic_auth
	# block: a username and a bcrypt hash of the password generated on the
	# server. Mode 640, owned by root, readable by the caddy group.
	import /etc/caddy/languagetool-auth.conf

	# 8149 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8149
}
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-languagetool, reload, and report what it objected to. Caddy
requests the certificate on the first request to the hostname and renews it on its own.

## 6. Firewall

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

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

80/tcp redirects to HTTPS and answers the ACME challenge, 443/tcp is the only way in, and
443/udp is HTTP/3. 8149 stays closed because it is bound to 127.0.0.1, and opening it would
put an unauthenticated grammar API on the public internet, a free CPU endpoint for whoever
finds it first. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp and
443/udp, and no rule for 8149.

## 7. Start and verify

The first start pulls about 430 MB and then loads dictionaries, so the loop below is patient.

```bash
cd /srv/languagetool
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8149/v2/languages); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://127.0.0.1:8149/v2/languages | head -c 200
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/v2/languages
curl -sS --netrc-file /srv/languagetool/.netrc -d "language=en-US" -d "text=I has a apple." https://<DOMAIN>/v2/check
```

Assert, all four, and print what you received for each. The loop ends printing `200`. The
languages listing contains `"longCode":"en-US"`. The unauthenticated call to the public
hostname prints `401`, Caddy refusing a request with no credential, and that is the security
assert here: an open LanguageTool on a public name is a free compute endpoint. The last
command returns JSON containing `"name":"LanguageTool"` and a match whose rule is
`"id":"EN_A_VS_AN"`, the engine finding the error in `a apple`. If any of the four misses,
stop, run `docker compose logs --tail 40 languagetool`, and name the likely earlier step: a
`502` from the public call means the container is not up yet, a Java heap message in the log
means step 1 ran on a box under the floor. A running container is not success.

There is no first screen. LanguageTool has no web interface, and https://<DOMAIN>/ answers
`404` with the body `Not found` once past the login box, because the server only handles paths
under `/v2/`. What a human puts into an editor or add-on is `https://<DOMAIN>/v2`, with the
username `languagetool` and the password below.

STOP: tell the user to read their password with
`sudo cat /srv/languagetool/api-password`, put it in their password manager, and wait.
Do not continue until they confirm. It is the only credential this install has, and the one
thing between the public internet and a machine that will check grammar for anybody.

## 8. First backup and restore

One archive, smaller than any other backup on this site because there is no user data to lose:
the credential and the four files that rebuild the service around it. Nothing is stopped,
because nothing is being written:

```bash
cd /srv/languagetool
sudo tar -czf /srv/languagetool/backups/languagetool-$(date +%F).tar.gz -C /srv/languagetool compose.yml api-password .netrc -C /etc/caddy Caddyfile languagetool-auth.conf
ls -lh /srv/languagetool/backups/
```

Assert: the archive exists and is non-empty. Print its size. It carries the password in two
forms, so treat it as the secret it is. A backup on the same disk is not a backup, so run this
from the user's machine:

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

To restore on a fresh box: untar the archive into /srv/languagetool, move the two Caddy files
back to /etc/caddy, `sudo systemctl reload caddy`, then `docker compose up -d` and re-run
step 7's four asserts. Tell the user the honest version: losing this archive costs the
password and ten minutes, not their writing, which was never stored here.

## 9. Updating later

LanguageTool tags releases at https://github.com/languagetool-org/languagetool/tags and the
community image that carries them is tagged at
https://hub.docker.com/r/erikvl87/languagetool/tags. Check the second: the packaging is a
separate project, so a new upstream tag is not installable until an image is built for it.
Take the backup first, then edit the image line in /srv/languagetool/compose.yml to the new
tag and digest:

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

Watch that log until the server reports it is listening, then re-run step 7's four asserts
before calling the update done.

## 10. What will probably go wrong

The browser add-on. I put `https://<DOMAIN>/v2` into the LanguageTool extension's own server
box, waited for my writing to start getting checked, and got nothing: no error, no underlines,
silence. The add-on sends a URL and nothing else, so it never answers the password prompt
Caddy is holding out, and a check that failed looks exactly like a sentence with no mistakes
in it. That is the trade this path makes, better said now than discovered in an hour. What
works here is every tool where the user controls the request: curl, scripts, CI prose checks,
and an `ssh -N -L 8149:127.0.0.1:8149 vps` tunnel from their laptop, which puts the server on
their own `http://localhost:8149/v2` where the add-on is content. For the add-on with no
tunnel, the local path on this page is the honest answer.

## 11. Out of scope

- Do not remove the `import /etc/caddy/languagetool-auth.conf` line and do not publish 8149.
  That line is the whole access control on this install.
- Do not download the n-gram data. It is roughly 8 GB per language, exists for four languages,
  and buys better detection of confusion pairs like `their` and `there`. This install trades
  that for a service that fits on a small VPS.
- Do not configure a LanguageTool Premium account and do not set any `langtool_premium`
  variable. Premium is a paid service of the LanguageTool company, the open-source server
  refuses those settings, and the rules it sells are not in this image.
- Do not add a second container for a web interface. LanguageTool ships none; the client is
  whatever the user already writes in.
````

## 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 LanguageTool 6.8 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 this install at all. The
LanguageTool HTTP server has no accounts, no API key and no login of its own, and the image
starts it with `--public` and `--allow-origin '*'`. On a public hostname that is a free CPU
endpoint for whoever finds it, so step 5 puts a password on the door. That password is
checked by Caddy, and the LanguageTool browser add-on cannot answer it: see step 10 before
you decide this is the path you want.

## 1. Preflight

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

You should see: at least `2048` MB available, at least `5` G free, `amd64` or `arm64`, a
Caddy version of `2.8` or newer, and your server's IP on the last line.

If you do not: a Caddy older than 2.8 does not know the `basic_auth` directive step 5 uses,
because that release renamed `basicauth`, so upgrade Caddy before going on. An empty last line
means the A record does not exist yet: add it, wait a minute, run `dig +short <DOMAIN>` again,
because Caddy cannot get a certificate for a name that does not resolve and failed attempts
count against a rate limit you cannot see. Under 2048 MB of RAM is not a warning to work
around: the Java heap ceiling in step 4 is 1 GB, and the OOM killer arrives in the middle of a
check rather than at start-up, which looks like random failure and is not.

## 2. Layout

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

You should see: `backups`, owned by you, and nothing else.

If you do not: there is deliberately no `data` directory here. LanguageTool reads its rules
and dictionaries out of the image and keeps nothing between requests, so text arrives, is
checked, is answered, and is dropped. If you were expecting a folder your writing accumulates
in, there is not one, and that is the best thing about this install.

## 3. Secrets

One secret: the password on the login box in front of the API. It is generated here, on the
server, and goes straight into two files only you can read.

```bash
umask 077
openssl rand -hex 24 > /srv/languagetool/api-password
printf 'machine <DOMAIN> login languagetool password %s\n' "$(cat /srv/languagetool/api-password)" > /srv/languagetool/.netrc
chmod 600 /srv/languagetool/api-password /srv/languagetool/.netrc
umask 022
ls -l /srv/languagetool/api-password /srv/languagetool/.netrc
```

You should see: two files, both mode `-rw-------`, your own username twice on each line.
Replace `<DOMAIN>` on the `printf` line with your real hostname before you paste. Read the
password once with `sudo cat /srv/languagetool/api-password` and put it in your password
manager: it is the only credential this install has, and every tool you point at this server
will ask for it. The username is `languagetool`.

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 into different shells. Run
`chmod 600 /srv/languagetool/api-password /srv/languagetool/.netrc` and carry on. If the files
already existed from an earlier attempt, this block has replaced the password, and step 5 has
to be run again afterwards or Caddy will keep checking the old one.

Do not paste either of those files, the password itself, or any output containing it into this
chat window. The `.netrc` exists so that curl can read the credential from a file instead of
from a command line, which keeps it out of the process list and out of your shell history;
pasting it into a chat undoes all of that at once.

## 4. compose.yml

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

```bash
cat > /srv/languagetool/compose.yml <<'EOF'
# LanguageTool · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   http server ........ https://dev.languagetool.org/http-server
#   upstream readme .... https://github.com/languagetool-org/languagetool/blob/v6.8/README.md
#   image readme ....... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/README.md
#   image dockerfile ... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/Dockerfile
#
# One service and no database. LanguageTool loads its rules and dictionaries out
# of the image and keeps nothing between requests: no volume, nothing to
# migrate, nothing on disk to lose.
#
# The LanguageTool project publishes no Docker image. Its README names three
# community-contributed Dockerfiles and this install uses one of them,
# Erikvl87/docker-languagetool, LGPL-2.1 like LanguageTool itself. That
# Dockerfile clones the upstream v6.8 tag and builds it with Maven, so the code
# is upstream's and the packaging is somebody else's. Tag and digest were read
# from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  languagetool:
    image: erikvl87/languagetool:6.8@sha256:ef8fa12cbd485166c9ceeb7139d76d56d07707a624da6bb1fc1fbb5411750527
    container_name: languagetool
    restart: unless-stopped
    environment:
      # The image's start script reads these two and defaults to 256m and 512m.
      # 512m runs out of room once several languages load, and the RAM floor in
      # the install accounts for the larger ceiling.
      Java_Xms: 512m
      Java_Xmx: 1g
      # Every langtool_* variable becomes one line in the server's
      # config.properties. This one caps a single request so one enormous paste
      # cannot hold the whole JVM. 40000 characters is a long document.
      langtool_maxTextLength: "40000"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8149. The
      # image starts the server with --public and --allow-origin '*', so it
      # answers whoever reaches it. Nothing else may.
      - "127.0.0.1:8149:8010"
    # The image ships a HEALTHCHECK that posts a sentence to /v2/check, so
    # `docker compose ps` reports healthy or unhealthy without help from here.
EOF
cd /srv/languagetool && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `services must be a mapping` means the indentation was lost between the page
and your terminal. Run `rm /srv/languagetool/compose.yml` and paste again in one go. There is
no `env_file` line and that is deliberate: no secret ever enters this container, because the
image's start script copies every `langtool_` variable into a config file and then prints that
file to the container log, where anyone who can run `docker compose logs` would read it.

## 5. Caddy and TLS

Two files. First the credential Caddy checks, which is a bcrypt hash of the password step 3
generated, written where the caddy user can read it and nowhere else.

```bash
umask 077
caddy hash-password < /srv/languagetool/api-password > /srv/languagetool/auth.hash
printf 'basic_auth {\n\tlanguagetool %s\n}\n' "$(cat /srv/languagetool/auth.hash)" > /srv/languagetool/auth.conf
umask 022
sudo install -m 640 -o root -g caddy /srv/languagetool/auth.conf /etc/caddy/languagetool-auth.conf
rm -f /srv/languagetool/auth.hash /srv/languagetool/auth.conf
sudo grep -c basic_auth /etc/caddy/languagetool-auth.conf
```

You should see: `1`.

If you do not: `unknown command hash-password` means your Caddy predates 2.8 and step 1 was
skipped. `chown: invalid group: caddy` means Caddy was installed some way other than its own
package, so find the group its service runs as with `systemctl show -p User -p Group caddy`
and use that name instead. The password goes into `hash-password` from a file rather than as
an argument on purpose: an argument is visible in the process list to every user on the box.

Now the site block. Replace `<DOMAIN>` 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-languagetool
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# LanguageTool · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://dev.languagetool.org/http-server,
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. The LanguageTool
# HTTP server has no accounts and no API key of its own, and the image starts it
# with --public and --allow-origin '*', so it answers whoever reaches it. This
# block is the only door on the install: Caddy checks one credential before a
# byte reaches the container. Needs Caddy 2.8, where basicauth became basic_auth.

<DOMAIN> {
	# JSON in, JSON out. A check response for a long document compresses well,
	# and there is no HTML here to frame or to sniff.
	encode zstd gzip

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

	# The credential is not in this file, because this file is published. The
	# install writes /etc/caddy/languagetool-auth.conf with one basic_auth
	# block: a username and a bcrypt hash of the password generated on the
	# server. Mode 640, owned by root, readable by the caddy group.
	import /etc/caddy/languagetool-auth.conf

	# 8149 is the loopback port compose publishes on this host. It is not a
	# container port and it is not open in the firewall.
	reverse_proxy 127.0.0.1:8149
}
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-languagetool /etc/caddy/Caddyfile`,
reload, and paste again. `unrecognized directive: basic_auth` inside the imported file is the
same Caddy version problem as above. Caddy requests the certificate on the first request to
the hostname 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 `8149`.

If you do not: delete anything for 8149 with `sudo ufw delete allow 8149`. That port is bound
to 127.0.0.1 by the compose file, and opening it would route around the login box you built in
step 5, leaving an unauthenticated grammar API on the public internet. 80/tcp redirects to
HTTPS and answers the ACME challenge, 443/tcp is the only way in, and 443/udp is HTTP/3, which
Caddy offers by default. `Status: inactive` is a different problem: Prompt Zero left this
firewall enabled, so something turned it off since, and `sudo ufw enable` puts it back.

## 7. Start and verify

The first start pulls about 430 MB and then loads dictionaries, so the loop is patient.

```bash
cd /srv/languagetool
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8149/v2/languages); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://127.0.0.1:8149/v2/languages | head -c 200
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/v2/languages
curl -sS --netrc-file /srv/languagetool/.netrc -d "language=en-US" -d "text=I has a apple." https://<DOMAIN>/v2/check
```

You should see, in order: the loop reaching `200`, a JSON list containing
`"longCode":"en-US"`, then `401`, then a JSON object containing `"name":"LanguageTool"` and a
match whose rule is `"id":"EN_A_VS_AN"`.

If you do not: the `401` is the one worth understanding. It means Caddy is refusing a request
that carried no credential, which is exactly what should happen, so seeing it is good news and
seeing `200` in its place means the `import` line is not doing its job and your server is open.
A `502` from the same command means the container is not up yet, so watch the loop again. A
Java heap message in `docker compose logs --tail 40 languagetool` means step 1 was run on a box
under the floor. A running container is not success.

There is no first screen and no web interface. https://<DOMAIN>/ answers `404` with the body
`Not found` once you are past the login box, because the server only handles paths under
`/v2/`. What you put into an editor or add-on is `https://<DOMAIN>/v2`, with the username
`languagetool` and the password from step 3.

## 8. First backup and restore

One archive, smaller than any other backup on this site because there is no user data to lose:
the credential and the four files that rebuild the service around it. Nothing goes offline,
because nothing is being written.

```bash
cd /srv/languagetool
sudo tar -czf /srv/languagetool/backups/languagetool-$(date +%F).tar.gz -C /srv/languagetool compose.yml api-password .netrc -C /etc/caddy Caddyfile languagetool-auth.conf
ls -lh /srv/languagetool/backups/
```

You should see: one file, a few kilobytes.

If you do not: `tar: Caddyfile: Cannot stat` means the second `-C` did not take, so check that
you pasted the whole line. An archive of about 100 bytes means every named file was missing,
which means one of the earlier steps did not run.

That archive contains the password in two forms, so treat it as the secret it is. 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/languagetool
scp vps:/srv/languagetool/backups/*.tar.gz ~/backups/languagetool/
```

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

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/languagetool
docker compose down
rm -f /srv/languagetool/compose.yml
tar -xzf /srv/languagetool/backups/languagetool-$(date +%F).tar.gz -C /srv/languagetool compose.yml
docker compose up -d
sleep 30
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/v2/languages
```

You should see: `401` again from the last command, which means Caddy is up, the site block
survived, and the container came back from a compose file that came out of the archive.

If you do not: `tar: compose.yml: Not found in archive` means the archive was written before
step 4, so take it again. The honest version of this whole block is worth saying out loud:
losing this archive costs you the password and ten minutes, not your writing, because your
writing was never stored here.

## 9. Updating later

LanguageTool tags releases at https://github.com/languagetool-org/languagetool/tags and the
community image that carries them is tagged at
https://hub.docker.com/r/erikvl87/languagetool/tags. Check the second: the packaging is a
separate project, so a new upstream tag is not installable until an image is built for it.
Take the backup first, then edit the `image:` line in /srv/languagetool/compose.yml to the new
tag and digest.

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

You should see: the server reporting it is listening, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run
step 7's four asserts before you call the update done, including the grammar check, because a
server that answers on `/v2/languages` can still be failing to load a dictionary it needs.

## 10. What will probably go wrong

The browser add-on. I put `https://<DOMAIN>/v2` into the LanguageTool extension's own server
box, waited for my writing to start getting checked, and got nothing: no error, no underlines,
silence. The add-on sends a URL and nothing else, so it never answers the password prompt
Caddy is holding out, and a check that failed looks exactly like a sentence with no mistakes
in it. That is the trade this path makes, better said now than discovered in an hour. What
works here is every tool where you control the request: curl, scripts, CI prose checks, and an
`ssh -N -L 8149:127.0.0.1:8149 vps` tunnel from your laptop, which puts the server on your own
`http://localhost:8149/v2` where the add-on is content. For the add-on with no tunnel, the
local path on this page is the honest answer.

## 11. Out of scope

- Do not remove the `import /etc/caddy/languagetool-auth.conf` line and do not publish 8149.
  That line is the whole access control on this install.
- Do not download the n-gram data. It is roughly 8 GB per language, exists for four languages,
  and buys better detection of confusion pairs like `their` and `there`. This install trades
  that for a service that fits on a small VPS.
- Do not configure a LanguageTool Premium account and do not set any `langtool_premium`
  variable. Premium is a paid service of the LanguageTool company, the open-source server
  refuses those settings, and the rules it sells are not in this image.
- Do not add a second container for a web interface. LanguageTool ships none; the client is
  whatever you already write in.
````

## 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 LanguageTool 6.8 under ~/selfhost/languagetool, answering at http://localhost:8149,
so the browser add-on and editor plugins on this computer check writing without sending it
anywhere.

## 1. Preflight

Say this to the user before anything installs. The checking they are about to get exists on
this computer and nowhere else: their phone, tablet and second laptop cannot reach
http://localhost:8149, so writing done there goes unchecked. In exchange, not one sentence
they type leaves this machine, which is the thing the paid product cannot offer at any price.

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. LanguageTool is a Java service that loads
dictionaries for every language it knows, and needs 2048 MB of RAM available and 5 GB free on
the home disk. The image publishes amd64 and arm64. Every branch prints free memory, so one
floor covers all three; on macOS and Windows it is the host's, and Docker Desktop's virtual
machine takes its allocation out of it. If available RAM is under 2048 MB or free disk is
under 5 GB, print both numbers and stop. Do not install and hope: the heap ceiling in step 5
is 1 GB and a machine under the floor fails mid-check, not at start-up.

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

Assert: `ls -la` shows `backups`, owned by the user. There is no `data` folder and there will
not be one, so there is no ownership fix to make on any of the three systems. LanguageTool
reads its rules and dictionaries out of the image and keeps nothing between requests: text
arrives, is checked, is answered, and is dropped. Step 8 is short because of it.

## 4. Secrets

None, and that is a real answer rather than a skipped step. No account to create, no API key
to mint, no password to set, because the LanguageTool HTTP server has no login of its own. On
a public server that absence has to be covered by a password at the reverse proxy, and the
server path for this app generates one. Here the boundary is the loopback binding instead,
which step 6 explains. Generate nothing and move on.

## 5. compose.yml

```bash
cat > ~/selfhost/languagetool/compose.yml <<'EOF'
# LanguageTool · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   http server ........ https://dev.languagetool.org/http-server
#   upstream readme .... https://github.com/languagetool-org/languagetool/blob/v6.8/README.md
#   image readme ....... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/README.md
#   image dockerfile ... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/Dockerfile
#
# One service on the computer you are sitting at. No bind mount and no named
# volume: LanguageTool loads its rules and dictionaries out of the image and
# keeps nothing between requests, so the text you check is read, answered and
# dropped, and none of it is written to this disk.
#
# The LanguageTool project publishes no Docker image. Its README names three
# community-contributed Dockerfiles and this install uses one of them,
# Erikvl87/docker-languagetool, LGPL-2.1 like LanguageTool itself. That
# Dockerfile clones the upstream v6.8 tag and builds it with Maven, so the code
# is upstream's and the packaging is somebody else's. Tag and digest were read
# from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  languagetool:
    image: erikvl87/languagetool:6.8@sha256:ef8fa12cbd485166c9ceeb7139d76d56d07707a624da6bb1fc1fbb5411750527
    container_name: languagetool
    restart: unless-stopped
    environment:
      # The image's start script reads these two and defaults to 256m and 512m.
      # 512m runs out of room once several languages load, and the RAM floor in
      # the install accounts for the larger ceiling.
      Java_Xms: 512m
      Java_Xmx: 1g
      # Every langtool_* variable becomes one line in the server's
      # config.properties. This one caps a single request so one enormous paste
      # cannot hold the whole JVM. 40000 characters is a long document.
      langtool_maxTextLength: "40000"
    ports:
      # Loopback only: no other device on the wifi reaches 8149 and nothing on
      # the internet does. The image starts the server with --public and
      # --allow-origin '*', so this binding is the whole boundary.
      - "127.0.0.1:8149:8010"
    # The image ships a HEALTHCHECK that posts a sentence to /v2/check, so
    # `docker compose ps` reports healthy or unhealthy without help from here.
EOF
cd ~/selfhost/languagetool && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, no volume of any kind.

## 6. Nothing is public

No reverse proxy, no certificate, no firewall rule and no login screen. 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 anyway, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
- No login. This is the honest part and the user should hear it plainly: the LanguageTool
  server checks nobody's credentials, and the image starts it with `--public` and
  `--allow-origin '*'`, which means it answers whoever reaches it. Whoever reaches it is the
  boundary, and here that is this computer alone.

8149 is bound to 127.0.0.1. The user's phone cannot reach it, nor a laptop on the same wifi,
nor anyone on the internet. Confirm it:

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

Assert: that prints `1`, the one published port, `- "127.0.0.1:8149:8010"`. If it ever reads
`0.0.0.0:8149`, this install has handed a free grammar API to every network the user joins.

## 7. Start and verify

The first start pulls about 430 MB and then loads dictionaries, so the loop below is patient.

```bash
cd ~/selfhost/languagetool
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8149/v2/languages); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8149/v2/languages | head -c 200
curl -sS -d "language=en-US" -d "text=I has a apple." http://localhost:8149/v2/check
```

Assert all three, and print what you received for each: the loop ends on `200`; the languages
listing contains `"longCode":"en-US"`; the check returns JSON with `"name":"LanguageTool"` in
it and a match whose rule is `"id":"EN_A_VS_AN"`, the engine finding the error in `a apple`.
If any of the three misses, stop, run `docker compose logs --tail 40 languagetool`,
and name the likely cause: a Java heap message means step 1 ran on a machine under the floor,
and `port is already allocated` means something else on this computer holds 8149. Find it with
`lsof -nP -iTCP:8149 -sTCP:LISTEN`, or `ss -ltnp | grep 8149` on Linux, or
`netstat -ano | findstr :8149` on Windows, and stop until the user frees it. A running
container is not success.

There is no first screen. LanguageTool has no web interface, and http://localhost:8149/
answers `404` with the body `Not found`, which is correct rather than broken: the server only
handles paths under `/v2/`.

STOP: tell the user to open the settings of the LanguageTool add-on in their browser, find the
advanced or experimental section where a different server can be chosen, enter
`http://localhost:8149/v2`, save, then type `I has a apple.` into any text box and wait for
the underlines. Wait for them to confirm they see them. Do not continue until they confirm.
That sentence going from their browser to this container and back is the only proof that
matters, and it is the whole product.

## 8. First backup and restore

The shortest backup block on this site, and the reason is the point of the install: no
database, no upload folder, no settings file, so nothing the user writes is here to lose.
What is worth keeping is the pinned compose file, which reproduces this exact version rather
than whatever is current later.

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

Assert: the archive exists and is non-empty. Print its size. Nothing is stopped, because
nothing is being written.

The archive sits on the same disk as the install, which is not a backup, and on a laptop the
disk and the machine fail together. Ask the user for a destination that leaves this computer,
a 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`; confirm the destination exists before
copying. Assert: the user confirms the filename is listed there.

To restore, on this machine or a new one: create `~/selfhost/languagetool`, untar the archive
into it, and run `docker compose up -d`. Then re-run step 7's three asserts. Tell the user the
honest version, because it is unusual and good news: this install has no disaster to plan for,
only a version to remember.

## 9. Updating later

LanguageTool tags releases at https://github.com/languagetool-org/languagetool/tags and the
community image that carries them is tagged at
https://hub.docker.com/r/erikvl87/languagetool/tags. Check the second: the packaging is a
separate project, so a new upstream tag is not installable until an image is built for it.
Take the backup first, then edit the image line in ~/selfhost/languagetool/compose.yml to the
new tag and digest:

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

Watch that log until the server reports it is listening, then re-run step 7's three asserts
before calling the update done.

## 10. What will probably go wrong

I rebooted, wrote for twenty minutes, and thought I had suddenly become a careful writer.
Nothing was wrong with my sentences: Docker Desktop had not started with the session, nothing
was listening on 8149, and the add-on had quietly stopped underlining anything. A grammar
checker that is down looks exactly like clean prose, which is the worst failure mode a tool
can have. Turn on Docker Desktop's start-at-login setting, and if the underlines ever stop
appearing, run `curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8149/v2/languages`
before believing your own writing.

## 11. Out of scope

- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8149 to 0.0.0.0 so a phone or another laptop can reach it. This server checks
  nobody's credentials, so on a shared wifi that is an open compute endpoint.
- Do not download the n-gram data. It is roughly 8 GB per language, exists for four languages,
  and buys better detection of confusion pairs like `their` and `there`. This install trades
  that for something that fits on a laptop.
- Do not configure a LanguageTool Premium account and do not set any `langtool_premium`
  variable. Premium is a paid service of the LanguageTool company, the open-source server
  refuses those settings, and the rules it sells are not in this image.
````

## docker-compose.yml

```yaml
# LanguageTool · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   http server ........ https://dev.languagetool.org/http-server
#   upstream readme .... https://github.com/languagetool-org/languagetool/blob/v6.8/README.md
#   image readme ....... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/README.md
#   image dockerfile ... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/Dockerfile
#
# One service and no database. LanguageTool loads its rules and dictionaries out
# of the image and keeps nothing between requests: no volume, nothing to
# migrate, nothing on disk to lose.
#
# The LanguageTool project publishes no Docker image. Its README names three
# community-contributed Dockerfiles and this install uses one of them,
# Erikvl87/docker-languagetool, LGPL-2.1 like LanguageTool itself. That
# Dockerfile clones the upstream v6.8 tag and builds it with Maven, so the code
# is upstream's and the packaging is somebody else's. Tag and digest were read
# from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  languagetool:
    image: erikvl87/languagetool:6.8@sha256:ef8fa12cbd485166c9ceeb7139d76d56d07707a624da6bb1fc1fbb5411750527
    container_name: languagetool
    restart: unless-stopped
    environment:
      # The image's start script reads these two and defaults to 256m and 512m.
      # 512m runs out of room once several languages load, and the RAM floor in
      # the install accounts for the larger ceiling.
      Java_Xms: 512m
      Java_Xmx: 1g
      # Every langtool_* variable becomes one line in the server's
      # config.properties. This one caps a single request so one enormous paste
      # cannot hold the whole JVM. 40000 characters is a long document.
      langtool_maxTextLength: "40000"
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8149. The
      # image starts the server with --public and --allow-origin '*', so it
      # answers whoever reaches it. Nothing else may.
      - "127.0.0.1:8149:8010"
    # The image ships a HEALTHCHECK that posts a sentence to /v2/check, so
    # `docker compose ps` reports healthy or unhealthy without help from here.
```

## compose.local.yml

```yaml
# LanguageTool · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   http server ........ https://dev.languagetool.org/http-server
#   upstream readme .... https://github.com/languagetool-org/languagetool/blob/v6.8/README.md
#   image readme ....... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/README.md
#   image dockerfile ... https://github.com/Erikvl87/docker-languagetool/blob/v6.8/Dockerfile
#
# One service on the computer you are sitting at. No bind mount and no named
# volume: LanguageTool loads its rules and dictionaries out of the image and
# keeps nothing between requests, so the text you check is read, answered and
# dropped, and none of it is written to this disk.
#
# The LanguageTool project publishes no Docker image. Its README names three
# community-contributed Dockerfiles and this install uses one of them,
# Erikvl87/docker-languagetool, LGPL-2.1 like LanguageTool itself. That
# Dockerfile clones the upstream v6.8 tag and builds it with Maven, so the code
# is upstream's and the packaging is somebody else's. Tag and digest were read
# from Docker Hub on 2026-08-06; the image publishes amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  languagetool:
    image: erikvl87/languagetool:6.8@sha256:ef8fa12cbd485166c9ceeb7139d76d56d07707a624da6bb1fc1fbb5411750527
    container_name: languagetool
    restart: unless-stopped
    environment:
      # The image's start script reads these two and defaults to 256m and 512m.
      # 512m runs out of room once several languages load, and the RAM floor in
      # the install accounts for the larger ceiling.
      Java_Xms: 512m
      Java_Xmx: 1g
      # Every langtool_* variable becomes one line in the server's
      # config.properties. This one caps a single request so one enormous paste
      # cannot hold the whole JVM. 40000 characters is a long document.
      langtool_maxTextLength: "40000"
    ports:
      # Loopback only: no other device on the wifi reaches 8149 and nothing on
      # the internet does. The image starts the server with --public and
      # --allow-origin '*', so this binding is the whole boundary.
      - "127.0.0.1:8149:8010"
    # The image ships a HEALTHCHECK that posts a sentence to /v2/check, so
    # `docker compose ps` reports healthy or unhealthy without help from here.
```

## Caddyfile

```text
# LanguageTool · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://dev.languagetool.org/http-server,
# https://caddyserver.com/docs/automatic-https and
# https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. The LanguageTool
# HTTP server has no accounts and no API key of its own, and the image starts it
# with --public and --allow-origin '*', so it answers whoever reaches it. This
# block is the only door on the install: Caddy checks one credential before a
# byte reaches the container. Needs Caddy 2.8, where basicauth became basic_auth.

<DOMAIN> {
	# JSON in, JSON out. A check response for a long document compresses well,
	# and there is no HTML here to frame or to sniff.
	encode zstd gzip

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

	# The credential is not in this file, because this file is published. The
	# install writes /etc/caddy/languagetool-auth.conf with one basic_auth
	# block: a username and a bcrypt hash of the password generated on the
	# server. Mode 640, owned by root, readable by the caddy group.
	import /etc/caddy/languagetool-auth.conf

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

## install.sh

```bash
#!/usr/bin/env bash
# LanguageTool · 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=lt.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://dev.languagetool.org/http-server
#   https://github.com/languagetool-org/languagetool/blob/v6.8/README.md
#   https://github.com/Erikvl87/docker-languagetool/blob/v6.8/README.md
#   https://github.com/Erikvl87/docker-languagetool/blob/v6.8/Dockerfile
#   https://caddyserver.com/docs/caddyfile/directives/basic_auth
#
# One secret is generated here, on this machine: the password Caddy checks in
# front of the API. It goes into /srv/languagetool/api-password with mode 600 and
# is never printed. The LanguageTool HTTP server has no accounts and no API key
# of its own, and the image starts it with --public and --allow-origin '*', so
# that Caddy credential is the only access control this install has.
#
# Needs Caddy 2.8 or newer: that release renamed basicauth to basic_auth.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/languagetool}"
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. lt.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"

caddy_ver="$(caddy version | head -1 | cut -d' ' -f1 | sed 's/^v//')"
caddy_major="${caddy_ver%%.*}"
caddy_rest="${caddy_ver#*.}"
caddy_minor="${caddy_rest%%.*}"
if [ "$caddy_major" -lt 2 ] || { [ "$caddy_major" -eq 2 ] && [ "$caddy_minor" -lt 8 ]; }; then
	die "caddy ${caddy_ver} predates 2.8, where the basic_auth directive this install uses arrived"
fi

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

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

# --- 2. Lay the files out ----------------------------------------------------
#
# No data directory, on purpose. LanguageTool reads its rules and dictionaries
# out of the image and keeps nothing between requests.

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 gets typed into settings boxes on other
# machines and hex has nothing a keyboard layout can ruin. Read it later with
#   sudo cat /srv/languagetool/api-password
# The .netrc is the same credential in the form curl reads from a file, which
# keeps it out of the process list and out of shell history.

if [ ! -f "$APP_DIR/api-password" ]; then
	umask 077
	openssl rand -hex 24 > "$APP_DIR/api-password"
	printf 'machine %s login languagetool password %s\n' "$DOMAIN_HOST" "$(cat "$APP_DIR/api-password")" > "$APP_DIR/.netrc"
	chmod 600 "$APP_DIR/api-password" "$APP_DIR/.netrc"
	umask 022
fi

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

# --- 4. The login box, then the site block, both on the host -----------------

umask 077
caddy hash-password < "$APP_DIR/api-password" > "$APP_DIR/auth.hash"
printf 'basic_auth {\n\tlanguagetool %s\n}\n' "$(cat "$APP_DIR/auth.hash")" > "$APP_DIR/auth.conf"
umask 022
sudo install -m 640 -o root -g caddy "$APP_DIR/auth.conf" /etc/caddy/languagetool-auth.conf
rm -f "$APP_DIR/auth.hash" "$APP_DIR/auth.conf"

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-languagetool"
	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 8149 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; 8149 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 -------------------------------------------------------------

docker compose pull
docker compose up -d

echo "==> waiting for the container on http://127.0.0.1:8149/v2/languages"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:8149/v2/languages" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "/v2/languages answered ${code:-nothing}. Check: docker compose logs --tail 40 languagetool"

curl -sS "http://127.0.0.1:8149/v2/languages" | grep -q '"longCode":"en-US"' \
	|| die "the languages listing has no en-US in it. Check: docker compose logs --tail 40 languagetool"

# Caddy must refuse a call that carries no credential. A 200 here means the
# import line is not doing its job and the server is open to the internet.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/v2/languages" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated call returned ${unauth}, not 401. Stop and investigate."

# End to end: a sentence in, a grammar match out, through Caddy and the login.
checked="$(curl -sS --netrc-file "$APP_DIR/.netrc" -d "language=en-US" -d "text=I has a apple." "https://${DOMAIN_HOST}/v2/check" || true)"
printf '%s' "$checked" | grep -q '"name":"LanguageTool"' || die "the check response did not name LanguageTool"
printf '%s' "$checked" | grep -q 'EN_A_VS_AN' || die "the check response found no EN_A_VS_AN match in 'a apple'"

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

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

cat <<-DONE

	LanguageTool is answering at https://${DOMAIN_HOST}/v2

	  1. There is no web interface, and https://${DOMAIN_HOST}/ returns 404.
	     That is correct: this is an API, not a site. Point an editor plugin
	     or a script at https://${DOMAIN_HOST}/v2 with the username
	     languagetool and the password below.
	  2. Your password is in $APP_DIR/api-password, mode 600. Read it with
	       sudo cat $APP_DIR/api-password
	     and put it in your password manager. It was not printed here.
	  3. The LanguageTool browser add-on cannot use this server. Its settings
	     box takes a URL and nothing else, so it never answers the password
	     Caddy asks for, and a check that failed looks exactly like writing
	     with no mistakes in it. Either tunnel to it from your own machine
	       ssh -N -L 8149:127.0.0.1:8149 vps
	     and point the add-on at http://localhost:8149/v2, or run the local
	     install on your own computer instead.
	  4. First backup written to $APP_DIR/backups. It holds the password, so
	     it is as sensitive as a credential and it is on the same disk as the
	     install, which is not a backup. Copy it somewhere else tonight.

DONE
```

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