Can I self-host Grammarly?

YES · ONE COMMAND— setup effort 1 of 4

YES — it's called LanguageTool. It takes one prompt, a 2048 MB VPS, and about 10 minutes. That is $12 a month you stop paying Grammarly — $144 a year on the Pro plan.

Why people pay for Grammarly

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.

Grammarly's product is not the grammar rules, it is the fact that they are already there. It sits in the email you are writing, the document, the message box and the phone keyboard, with no setup and nothing to point anywhere, and the paid tier adds the parts people actually reach for under deadline: rewriting a clumsy sentence, softening the tone of a reply, and telling you the draft reads as machine-written before your reader does.

Grammarly plans and list prices
PlanList priceWhat it buys
FreefreeListed as $0 USD / month. Spelling and grammar, tone display, and a monthly allowance of AI prompts.
Prothe plan this page prices against$12/moThe plans page shows $12 USD with a 7-day free trial and does not state the billing period next to the figure. Independent pricing trackers describe $12 a month as the annual-billed rate, with month-to-month closer to $30. Adds sentence rewriting, tone adjustment, plagiarism and AI detection, and 2,000 AI prompts per member per month.
Enterprisequote onlyQuote only. The page says Contact Sales and lists dedicated support, BYOK encryption, custom roles, data loss prevention and unlimited prompts per member.

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

Replaced by LanguageTool

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

The grammar and style engine behind the browser add-on, running on your own machine, with nothing you write leaving it.

The only one here that keeps the shape of what you are cancelling. It is a grammar and spelling engine for 25-plus languages with an HTTP API, and the clients built around it, the browser add-on and the editor plugins, carry a box for a different server address. What it will not give you is the paid rule set: LanguageTool the company sells 20,000-plus extra checks, a style guide and AI paraphrasing on top of the same open-source core, and self-hosting gets you the free half.

The swap

You're paying

Grammarly

$12/mo · $144/yr

is replaced by

You'd run

LanguageTool

ONE COMMAND · ~10 min to running · 2048 MB RAM

Grammarly Pro · vendor list price · checked 2026-08-06 · source · confidence: medium

Before you start

RAM floor
2048 MBfloor from upstream docs — not measured by us yet
Disk
5 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 LanguageTool: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.

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

Where it runs

312 lines · 14,970 bytes

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

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

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

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

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

Install 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.
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 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.

289 lines · 14,983 bytes

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

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

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

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

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

Install 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.
compose.local.ymlthe services, pinned · local layout43 lines

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

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

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

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

# 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.
Caddyfilethe hostname and TLS36 lines

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

# 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.shthe same install, no agent166 lines

authored from upstream docs, never pasted · 8,057 bytes

#!/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

What you're signing up for

The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Grammarly.

  • The engine is upstream's, the Docker packaging is not. LanguageTool publishes no image and names three community Dockerfiles in its own README; this install pins one of them, Erikvl87/docker-languagetool, LGPL-2.1, maintained outside the LanguageTool project. It clones the upstream v6.8 tag and builds it, so the code is upstream's, and the packaging's release cadence is one more thing you are trusting.
  • The premium rules are not in here, and no setting turns them on. LanguageTool the company sells more than 20,000 extra checks, a style guide, AI paraphrasing and a much larger text limit on top of the same open-source engine. Self-hosting gives you the free rule set, which is a real grammar and spelling checker for 25-plus languages, and not the paid one.
  • There is no login, anywhere in the software. The HTTP server checks nobody's credentials, and the image starts it with --public and --allow-origin '*', so on a public hostname the only lock is the one the reverse proxy holds. This install generates that password. The cost is that the LanguageTool browser add-on cannot answer it, so the add-on story lives on the local path or behind a tunnel.
  • Nothing you write is stored. There is no database, no volume and no upload folder: text arrives, is checked, is answered, and is dropped. That makes the backup here the shortest on this site, and it makes a lost server a ten-minute rebuild rather than a loss.
  • No mobile keyboard, no plagiarism or AI detection, no tone rewriting, no team style enforcement. Those are what Grammarly's subscription is actually selling, and none of them are in this box.

Where this came from

“For security reasons, the server will not be accessible from other hosts. If you want to run a server for remote users, use the --public option.”

  • The LanguageTool project publishes no Docker image of its own. Its README lists three community-contributed Dockerfiles instead, one of which is Erikvl87/docker-languagetool, the image this install pins. source
  • That community Dockerfile clones the upstream languagetool repository at the v6.8 tag and builds the standalone distribution with Maven, so the software is upstream's release and only the packaging is third-party. source
  • Upstream documents that the server is reachable from other hosts only with the --public option, and that --allow-origin is what lets the browser add-on talk to your own server. source
  • The image's start script turns every langtool_ environment variable into a line of the server's config.properties, sets the heap from Java_Xms and Java_Xmx, and starts the server on port 8010 with --public and --allow-origin '*'. source
  • LanguageTool Premium advertises more than 20,000 additional checks and a 150,000-character limit over the free version, none of which ship in the open-source server. source

Questions people actually ask

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

  • Can I self-host Grammarly?

    Not Grammarly 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 LanguageTool. The grammar and style engine behind the browser add-on, running on your own machine, with nothing you write leaving it. 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 Grammarly?

    LanguageTool. The grammar and style engine behind the browser add-on, running on your own machine, with nothing you write leaving it. The only one here that keeps the shape of what you are cancelling. It is a grammar and spelling engine for 25-plus languages with an HTTP API, and the clients built around it, the browser add-on and the editor plugins, carry a box for a different server address. What it will not give you is the paid rule set: LanguageTool the company sells 20,000-plus extra checks, a style guide and AI paraphrasing on top of the same open-source core, and self-hosting gets you the free half. LanguageTool is LGPL-2.1-or-later-licensed and free; nothing on this page is a hosted service we sell you.

  • What does self-hosting cost compared to Grammarly?

    2048 MB of RAM and 5 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. LanguageTool itself is free and LGPL-2.1-or-later-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Grammarly Pro, $12/mo — $144 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 LanguageTool install, not from anyone's impression of it, and the whole rubric is published on the methodology page.

  • Can I run LanguageTool 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 LanguageTool 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. Worth knowing: The checking happens on this computer and only for what you write on it, so your phone and a second laptop get nothing, and in exchange not one sentence you type leaves the machine. 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.