# Can I self-host GitHub Copilot?

**YES, IF** — it's called Tabby. ONGOING OPS setup · ~4 hours to running · 4 GB RAM minimum · $10/mo you stop paying ($120/yr on the Pro plan).

Tabby authored from upstream docs · not yet machine-verified · source: https://caniselfhostit.com/self-host/github-copilot/

## 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 Tabby 0.32.0 on that server, reachable at https://<DOMAIN>, behind the existing Caddy
with automatic TLS. That server needs an NVIDIA GPU.

## 1. Preflight

If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.

Say this to the user first, because it decides whether they want this at all. Tabby runs the
model on an NVIDIA GPU in this server. The published image is built against CUDA 12.4.1, its
inference process links `libcuda.so.1`, and there is no CPU-only build: upstream closed that
request in February 2026 and pointed at a third-party image. A rented GPU instance costs more
per month than a GitHub Copilot subscription, every month, whether or not anyone is typing.

Tabby needs 4096 MB of RAM available, 20 GB free on /srv, and a GPU with at least 8 GB of memory
whose driver reports CUDA 12.4 or newer. The image is linux/amd64 only. Measure:

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
nvidia-smi | head -3
dig +short <DOMAIN>
```

Stop, print the numbers, and install nothing if any of these is true: available RAM is under
4096 MB, free disk is under 20 GB, `dpkg --print-architecture` prints anything but `amd64`,
`nvidia-smi` is missing or reports no GPU, GPU memory is under 8000 MiB, the `CUDA Version` in
the `nvidia-smi` header is below 12.4, or `dig +short` prints nothing. This prompt installs no
NVIDIA driver: on a rented box that comes from the provider's image.

## 2. Layout and the GPU runtime

Two directories, then the piece Prompt Zero did not install: the NVIDIA Container Toolkit, which
lets Docker hand the card to a container.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/tabby /srv/tabby/backups
sudo install -d -m 700 /srv/tabby/data
sudo curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey -o /usr/share/keyrings/nvidia-container-toolkit.asc
sudo chmod a+r /usr/share/keyrings/nvidia-container-toolkit.asc
echo "deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit.asc] https://nvidia.github.io/libnvidia-container/stable/deb/amd64 /" | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker run --rm --gpus all --entrypoint nvidia-smi tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9 -L
ls -la /srv/tabby
```

Assert two things. `nvidia-smi -L` inside that container prints a `GPU 0:` line naming the card
step 1 saw, which proves the toolkit is wired into Docker rather than only installed. And
`ls -la` shows `backups` owned by the login user and `data` at mode `700` owned by root, which is
what Tabby sets its root to on every start. The signing key goes to a file and is named in the
apt source line; nothing is piped into a shell. A `could not select device driver` failure means
Docker never reloaded: run the restart again.

## 3. Secrets

One secret: the key Tabby signs session tokens with. Generate it on the server. Do not print it,
do not repeat it in your summary, and do not put it in any log line.

```bash
umask 077
cat > /srv/tabby/.env <<EOF
TABBY_WEBSERVER_JWT_TOKEN_SECRET=$(openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
EOF
chmod 600 /srv/tabby/.env
umask 022
ls -l /srv/tabby/.env
```

Assert: the file exists with mode `-rw-------`. The shape is not decorative. Tabby exits rather
than warning if this value does not parse as a UUID, so the `sed` puts 128 bits of `openssl`
entropy into the 8-4-4-4-12 form. Without the variable the server invents a new key on every
start, which signs every user out on every restart. Tell the user it lives in /srv/tabby/.env,
that `sudo grep TABBY_WEBSERVER /srv/tabby/.env` prints it, and that it belongs in their
password manager.

## 4. compose.yml

```bash
cat > /srv/tabby/compose.yml <<'EOF'
# Tabby · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ....... https://tabby.tabbyml.com/docs/quick-start/installation/docker-compose/
#   docker run ........... https://tabby.tabbyml.com/docs/quick-start/installation/docker/
#   models registry ...... https://tabby.tabbyml.com/docs/models/
#   upgrade .............. https://tabby.tabbyml.com/docs/administration/upgrade/
#
# One service, and it wants an NVIDIA GPU: the image is built from
# docker/Dockerfile.cuda against CUDA 12.4.1, its llama-server links
# libcuda.so.1, and there is no CPU-only build to fall back to. Everything
# Tabby keeps sits under TABBY_ROOT, which the image sets to /data: accounts in
# ee/db.sqlite, the search index, and three model files fetched on first start.
# Digests read on 2026-08-06, same on Docker Hub and ghcr.io; amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  tabby:
    image: tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9
    container_name: tabby
    restart: unless-stopped
    # Upstream's own quick start, plus the embedding model the config
    # defaults to, which is downloaded alongside these two.
    command: serve --model StarCoder-1B --chat-model Qwen2-1.5B-Instruct --device cuda
    env_file: /srv/tabby/.env
    environment:
      # No anonymous usage reports leave this box.
      TABBY_DISABLE_USAGE_COLLECTION: "1"
    volumes:
      - /srv/tabby/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8134.
      - "127.0.0.1:8134:8080"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # The dashboard answers unauthenticated; the /v1 API does not. The
      # long start period is the 3 GB model download on first boot.
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 600s
EOF
cd /srv/tabby && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount. The model
names are the pinned part of that choice: Tabby resolves each name against its registry and
checks the downloaded file against the SHA-256 the registry publishes, so a file that does not
match is fetched again rather than served.

## 5. Caddy and TLS

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-tabby
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Tabby · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://tabby.tabbyml.com/docs/quick-start/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Tabby serves a
# plain HTTP listener on 8080 and terminates nothing itself.

<DOMAIN> {
	# The dashboard bundle and the JSON responses compress well; Caddy's
	# default matcher leaves everything else alone.
	encode zstd gzip

	# Tabby sets no transport or frame headers of its own. HSTS is on
	# because every request here carries a session cookie or an editor's
	# access token.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8134 is the loopback port compose publishes here. It is not a
	# container port and it is not open in the firewall. Caddy flushes the
	# streamed completions as they arrive and upgrades the dashboard's
	# /subscriptions WebSocket with no extra configuration.
	reverse_proxy 127.0.0.1:8134
}
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-tabby, 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 Prompt Zero box 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 answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8134 stays closed because compose binds it to 127.0.0.1. The llama-server
processes take ports above 30888 on loopback inside the container's own namespace, so none of
them reaches this firewall. Assert: `ufw status verbose` prints `Status: active`, shows 80,
443/tcp and 443/udp, and no rule mentioning 8134 or 8080.

## 7. Start and verify

The first start downloads about 3 GB of models. Budget twenty minutes.

```bash
cd /srv/tabby
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 20; done
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized isChatEnabled allowSelfSignup } }"}' https://<DOMAIN>/graphql; echo
docker compose logs --tail 5 tabby
```

Assert all three, and print what you received for each: the loop ends printing `200`; the
GraphQL response contains `"isAdminInitialized":false` and `"isChatEnabled":true`; the log tail
shows the `Listening at` banner. That `false` means the server is up and nobody owns it yet. If
the loop never reaches 200, stop, run `docker compose logs --tail 60 tabby`, and name the likely
earlier step: a line about `libcuda.so.1` or `could not select device driver` is step 2, a
process that exits after saying something about a UUID is step 3, and a Caddy 502 over a healthy
container is step 5. A running container is not success.

STOP: tell the user to open https://<DOMAIN> and create their account, and wait. Do not continue
until they confirm. A server with no administrator redirects to
https://<DOMAIN>/auth/signup?isAdmin=true, whose first screen reads `Welcome!` above
`Your tabby server is live and ready to use.` with a `Start` button; the step after it is headed
`Create Admin Account` and says the password cannot be recovered. Have them save it as they
type it.

```bash
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized allowSelfSignup } }"}' https://<DOMAIN>/graphql; echo
```

Assert: `"isAdminInitialized":true` and `"allowSelfSignup":false`. The second half is the
security assert: self-registration needs SMTP plus an allowed email domain, and this
install has neither, so a second person gets in only by invitation. If `isAdminInitialized`
still prints `false`, the account was not created and the server is still claimable; do not go
on. Then tell the user the next step is theirs: an editor extension pointed at this server,
with a token from the dashboard.

## 8. First backup and restore

One archive: the accounts database, the compose file, the secret and the live Caddy site block.
The model files under data/models are deliberately not in it: 3 GB, re-downloadable, and checked
against a published SHA-256 on every start.

```bash
cd /srv/tabby
docker compose stop
sudo tar -czf /srv/tabby/backups/tabby-$(date +%F).tar.gz -C /srv/tabby data/ee compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/tabby/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped on purpose,
because a SQLite database copied mid-write is not a backup; the restart costs a model load, not a
download.

A backup on the same disk is not a backup. Run this one from the user's machine, not the server:

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

To restore: `docker compose down`, `sudo rm -rf /srv/tabby/data/ee`, untar the archive back into
/srv/tabby, put the Caddy block back if that is what was lost, then `docker compose up -d`.
Accounts, access tokens and connected repositories are in `data/ee/db.sqlite`; the key that
validates existing sessions is in `.env`, and a database restored without it signs everyone out.
Upstream states that Tabby does not support downgrading, so this archive is the only way back
from a version that goes wrong.

## 9. Updating later

New versions are listed at https://github.com/TabbyML/tabby/releases. The Docker tag drops the
leading `v`, so `v0.33.0` is tag `0.33.0`. Take the step 8 backup first, there is no downgrade
path, then edit the image line in compose.yml to the new tag and digest:

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

Tabby migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's GraphQL check before calling the update done.

## 10. What will probably go wrong

The first `docker compose up -d` returns in about a second and then nothing answers for a quarter
of an hour. I refreshed https://<DOMAIN> for five minutes, got a connection error every time, and
started reading the compose file for a mistake that was not there. Tabby was downloading 3 GB of
model weights before opening its listener, and `docker compose logs -f tabby` had been showing
the progress all along. Watch the log, not the browser, and do not restart the container to hurry
it: a restart mid-download leaves a partial file that fails its checksum and starts over.

## 11. Out of scope

- Do not switch to the `-cuda11` tag or to a community CPU build. The pinned tag is the CUDA
  12.4.1 image upstream publishes, and step 1 already refused a driver too old for it.
- Do not configure SMTP. Leaving it unset is what keeps self-registration off, which step 7
  asserts; a mail server quietly changes that answer.
- Do not connect a GitHub or GitLab account to index repositories. That is an OAuth application
  the user registers themselves, and this prompt installs the server it would talk to.
- Do not raise `--parallelism` or swap in a larger model. Both multiply the GPU memory needed,
  and step 1 measured the card for the two models in compose.yml.
````

## 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 Tabby 0.32.0 on a VPS where Prompt Zero is done: `ssh vps` works, Docker and
Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a step
says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at the
box.

Read this before step 1. Tabby runs its models on an NVIDIA GPU in that server. The published
image is built against CUDA 12.4.1, its inference process links `libcuda.so.1`, and there is no
CPU-only build: upstream closed that request in February 2026 and pointed at a third-party
image. A rented GPU instance costs more per month than a GitHub Copilot subscription, every
month, whether or not anyone is typing. If the box you have has no GPU, stop here rather than at
step 7.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
nvidia-smi | head -3
dig +short <DOMAIN>
```

You should see: at least `4096` MB available, at least `20` G free, `amd64`, one GPU line with
at least 8000 MiB of memory, a `CUDA Version` of 12.4 or higher in the `nvidia-smi` header, and
your server's IP on the last line.

If you do not: `nvidia-smi: command not found` means this machine has no NVIDIA driver, and this
install cannot proceed on it. Installing a kernel driver yourself on a rented box is a fight
with the provider's image and is out of scope here; rebuild the instance from a GPU image that
ships the driver. A `CUDA Version` below 12.4 means the driver is older than the image expects,
which shows up later as an inference process that starts and dies. An empty last line means the
A record does not exist yet: add it, wait a minute, and 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.

## 2. Layout and the GPU runtime

Prompt Zero installed Docker but not the NVIDIA Container Toolkit, which is what lets Docker
hand the card to a container. Paste this whole block.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/tabby /srv/tabby/backups
sudo install -d -m 700 /srv/tabby/data
sudo curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey -o /usr/share/keyrings/nvidia-container-toolkit.asc
sudo chmod a+r /usr/share/keyrings/nvidia-container-toolkit.asc
echo "deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit.asc] https://nvidia.github.io/libnvidia-container/stable/deb/amd64 /" | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker run --rm --gpus all --entrypoint nvidia-smi tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9 -L
ls -la /srv/tabby
```

You should see: a 1.6 GB image pull, then one `GPU 0:` line naming the same card step 1 saw,
then `backups` owned by you and `data` at mode `drwx------` owned by root.

If you do not: `could not select device driver with capabilities: [[gpu]]` means the toolkit is
installed but Docker never reloaded its configuration, so run `sudo systemctl restart docker`
again and repeat the `docker run` line. `E: Unable to locate package nvidia-container-toolkit`
means the apt source line did not land, so check that
/etc/apt/sources.list.d/nvidia-container-toolkit.list holds one line and re-run `apt-get update`.
Leave `data` owned by root: the container starts as root inside its own namespace and sets that
directory to 700 itself on every start.

## 3. Secrets

One secret: the key Tabby signs its session tokens with. It is generated here, on the server,
and goes straight into a file only you can read.

```bash
umask 077
cat > /srv/tabby/.env <<EOF
TABBY_WEBSERVER_JWT_TOKEN_SECRET=$(openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
EOF
chmod 600 /srv/tabby/.env
umask 022
ls -l /srv/tabby/.env
```

You should see: mode `-rw-------`, your own username twice, and the path. Read the value once
with `sudo grep TABBY_WEBSERVER /srv/tabby/.env` and put it in your password manager.

If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/tabby/.env` and carry
on. The 8-4-4-4-12 shape is not decoration: Tabby exits rather than warning if this value does
not parse as a UUID, and the `sed` is what turns 128 bits of `openssl` entropy into that shape.
Without the variable the server invents a new key every time it starts, which signs everyone out
on every restart.

Do not paste that file, the secret, or any command output containing it into this chat window.
The agent path never sees the value; this one will hand it to a third party unless you keep it
out.

## 4. compose.yml

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

```bash
cat > /srv/tabby/compose.yml <<'EOF'
# Tabby · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ....... https://tabby.tabbyml.com/docs/quick-start/installation/docker-compose/
#   docker run ........... https://tabby.tabbyml.com/docs/quick-start/installation/docker/
#   models registry ...... https://tabby.tabbyml.com/docs/models/
#   upgrade .............. https://tabby.tabbyml.com/docs/administration/upgrade/
#
# One service, and it wants an NVIDIA GPU: the image is built from
# docker/Dockerfile.cuda against CUDA 12.4.1, its llama-server links
# libcuda.so.1, and there is no CPU-only build to fall back to. Everything
# Tabby keeps sits under TABBY_ROOT, which the image sets to /data: accounts in
# ee/db.sqlite, the search index, and three model files fetched on first start.
# Digests read on 2026-08-06, same on Docker Hub and ghcr.io; amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  tabby:
    image: tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9
    container_name: tabby
    restart: unless-stopped
    # Upstream's own quick start, plus the embedding model the config
    # defaults to, which is downloaded alongside these two.
    command: serve --model StarCoder-1B --chat-model Qwen2-1.5B-Instruct --device cuda
    env_file: /srv/tabby/.env
    environment:
      # No anonymous usage reports leave this box.
      TABBY_DISABLE_USAGE_COLLECTION: "1"
    volumes:
      - /srv/tabby/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8134.
      - "127.0.0.1:8134:8080"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # The dashboard answers unauthenticated; the /v1 API does not. The
      # long start period is the 3 GB model download on first boot.
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 600s
EOF
cd /srv/tabby && docker compose config >/dev/null && echo "compose OK"
```

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

If you do not: `env file /srv/tabby/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal,
so run `rm /srv/tabby/compose.yml` and paste again in one go. The two model names are the pinned
part of the model choice: Tabby resolves each name against its registry and checks the file it
downloads against the SHA-256 the registry publishes, so a corrupted or substituted file is
fetched again rather than served.

## 5. Caddy and TLS

This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-tabby
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Tabby · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://tabby.tabbyml.com/docs/quick-start/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Tabby serves a
# plain HTTP listener on 8080 and terminates nothing itself.

<DOMAIN> {
	# The dashboard bundle and the JSON responses compress well; Caddy's
	# default matcher leaves everything else alone.
	encode zstd gzip

	# Tabby sets no transport or frame headers of its own. HSTS is on
	# because every request here carries a session cookie or an editor's
	# access token.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8134 is the loopback port compose publishes here. It is not a
	# container port and it is not open in the firewall. Caddy flushes the
	# streamed completions as they arrive and upgrades the dashboard's
	# /subscriptions WebSocket with no extra configuration.
	reverse_proxy 127.0.0.1:8134
}
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-tabby /etc/caddy/Caddyfile`, reload, and
paste again. Caddy requests the certificate on the first request to the hostname and renews it
on its own, so there is nothing to schedule and nothing to put in cron.

## 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 `8134` or `8080`.

If you do not: delete anything for 8134 with `sudo ufw delete allow 8134`. 8134 is bound to
127.0.0.1 by the compose file, so Caddy is the only thing that can reach it and a firewall rule
would only widen that. The inference processes Tabby starts take ports above 30888 on loopback
inside the container's own namespace and never reach the host at all. `Status: inactive` is a
different problem: Prompt Zero left this firewall enabled, so something has turned it off since,
and `sudo ufw enable` puts it back before you go any further.

## 7. Start and verify

The first start downloads about 3 GB of model files before anything answers. Expect the loop
below to print `000` for a long time. Do not interrupt it.

```bash
cd /srv/tabby
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 20; done
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized isChatEnabled allowSelfSignup } }"}' https://<DOMAIN>/graphql; echo
docker compose logs --tail 5 tabby
```

You should see, in order: the loop reaching `200`, then a JSON object containing
`"isAdminInitialized":false` and `"isChatEnabled":true`, then a log tail with the `Listening at`
banner in it.

If you do not: run `docker compose logs --tail 60 tabby` and read for one of three things. A
line containing `libcuda.so.1` or `could not select device driver` means step 2 did not finish,
so repeat it. A process that exits right after saying something about a UUID means the value in
.env is not in the 8-4-4-4-12 shape, so redo step 3. A Caddy `502` against a container that
`docker compose ps` calls healthy means step 5, not step 7. If the log is still printing download
progress, nothing is wrong: it is fetching three model files, and a restart mid-download leaves a
partial file that fails its checksum and is fetched again from the start.

Now open https://<DOMAIN> in a browser. A server with no administrator sends you to
https://<DOMAIN>/auth/signup?isAdmin=true, whose first screen reads `Welcome!` above
`Your tabby server is live and ready to use.` with a `Start` button. The step after it is headed
`Create Admin Account` and tells you the password cannot be recovered, which is true: there is no
mail server here to send you a reset. Save it as you type it, then confirm the window is closed:

```bash
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized allowSelfSignup } }"}' https://<DOMAIN>/graphql; echo
```

You should see: `"isAdminInitialized":true` and `"allowSelfSignup":false`.

If you do not: `"isAdminInitialized":false` means the account was not created and the next
stranger who loads that hostname can still claim your server, so go back and finish the form
before anything else. The `allowSelfSignup` half is the security check worth understanding.
Tabby offers self-registration only when an SMTP server and an allowed email domain are both
configured, and this install has neither, so from here a second person gets an account only from
an invitation you send. A green `docker compose ps` on its own is not success; these two fields
are. Your own editor is the last step: install a Tabby extension there and point it at this
server with a token from the dashboard.

## 8. First backup and restore

One archive: the accounts database, the compose file, the secret and the live Caddy site block.
The model files under data/models are deliberately left out. They are 3 GB, they are
re-downloadable, and Tabby checks them against a published SHA-256 on every start.

```bash
cd /srv/tabby
docker compose stop
sudo tar -czf /srv/tabby/backups/tabby-$(date +%F).tar.gz -C /srv/tabby data/ee compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/tabby/backups/
```

You should see: one file, a few hundred kilobytes on a fresh install. The container is stopped
for a few seconds on purpose, because a SQLite database copied mid-write is not a backup.

If you do not: a `tar: data/ee: Cannot stat` error means step 7 never got far enough to create
the database, so the server has not run yet and there is nothing to back up. Fix step 7 first.

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

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

If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is one account:

```bash
cd /srv/tabby
docker compose down
sudo rm -rf /srv/tabby/data/ee
sudo tar -xzf /srv/tabby/backups/tabby-$(date +%F).tar.gz -C /srv/tabby data/ee
docker compose up -d
sleep 60
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized } }"}' https://<DOMAIN>/graphql; echo
```

You should see: `"isAdminInitialized":true`, which means the account survived a database that was
deleted and put back.

If you do not: sixty seconds may not be enough, because the models are checked against their
checksums on the way up. Wait and run the last line again before concluding anything. Understand
what the stakes are: `data/ee/db.sqlite` holds your account, the access tokens your editors use,
and every repository you have connected, and `.env` holds the key that validates existing
sessions, so a database restored without its .env signs everyone out. Upstream states that Tabby
does not support downgrading, which makes this archive the only way back from an upgrade that
goes wrong.

## 9. Updating later

New versions are listed at https://github.com/TabbyML/tabby/releases. The Docker tag drops the
leading `v`, so release `v0.33.0` is image tag `0.33.0`. Take the backup from step 8 first,
because there is no downgrade path, then edit the `image:` line in /srv/tabby/compose.yml to the
new tag and its digest.

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

You should see: migration output, then the `Listening at` banner, and no repeating restart.

If you do not: put the old tag and digest back and run the same three commands. Then re-run the
GraphQL check from step 7 before you call the update done, because a server that answers on the
dashboard can still be failing to serve completions if a model file changed name upstream.

## 10. What will probably go wrong

The first `docker compose up -d` returns in about a second and then nothing answers for a quarter
of an hour. I refreshed https://<DOMAIN> for five minutes, got a connection error every time, and
started reading the compose file for a mistake that was not there. Tabby was downloading 3 GB of
model weights before opening its listener, and `docker compose logs -f tabby` had been showing
the progress all along. Watch the log, not the browser, and do not restart the container to hurry
it: a restart mid-download leaves a partial file that fails its checksum and starts over.

## 11. Out of scope

- Do not switch to the `-cuda11` tag or to a community CPU build. The pinned tag is the CUDA
  12.4.1 image upstream publishes, and step 1 already refused a driver too old for it.
- Do not configure SMTP. Leaving it unset is what keeps self-registration off, which step 7
  asserts; a mail server quietly changes that answer.
- Do not connect a GitHub or GitLab account to index repositories. That is an OAuth application
  you register yourself, and this install gives you the server it would talk to.
- Do not raise `--parallelism` or swap in a larger model. Both multiply the GPU memory needed,
  and step 1 measured the card for the two models in compose.yml.
````

## 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 Tabby 0.32.0 under ~/selfhost/tabby, answering at http://localhost:8134, with its models
running on the NVIDIA card in this computer.

## 1. Preflight

Say this to the user before step 2 runs, because it decides whether they want this at all. The
model runs on the GPU in this machine, so the answer to "is my code sent anywhere" is no and the
answer to "how fast is it" is however fast this card is. Nothing here is reachable from their
phone or a second laptop, which costs little: this only has to be awake while they type.

Detect the OS and measure the machine:

```bash
uname -s
uname -m
case "$(uname -s)" in
  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 ~
nvidia-smi --query-gpu=name,memory.total,driver_version --format=csv
nvidia-smi | head -3
```

`Linux` is Linux; `MINGW` or `MSYS` is Windows under Git Bash. Both are supported.

STOP if `uname -s` printed `Darwin`. Tell the user this prompt cannot install Tabby on a Mac and
do not continue. The published image is linux/amd64 only and built against CUDA, and Docker
Desktop on macOS reaches neither an x86 GPU nor Apple's own. Upstream ships a macOS binary that
uses Metal; that is a different install and this prompt does not cover it.

Stop and print the numbers if any of these is true: `uname -m` prints anything but `x86_64`,
`nvidia-smi` is missing or reports no GPU, GPU memory is under 8000 MiB, the `CUDA Version` in
the `nvidia-smi` header is below 12.4, available RAM is under 4096 MB, or the home disk has under
20 GB free. Do not install and hope. On Windows that reads the Windows driver, the one WSL 2
hands to Docker, so it is the number to trust.

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

- 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
  https://www.docker.com/products/docker-desktop/ and install it, and wait until they confirm.
  Docker Desktop configures WSL 2 itself and may ask for a reboot; if it does, STOP, tell the
  user to reboot and come back, and resume 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 package manager, and to run this prompt again once `docker info` works.

Assert: `docker info` exits 0 and `docker compose version` prints a version. Not one without the
other.

Docker also has to hand the card to a container. Windows does that through WSL 2; Linux needs the
NVIDIA Container Toolkit, from the next guarded fence:

```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
  sudo curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey -o /usr/share/keyrings/nvidia-container-toolkit.asc
  sudo chmod a+r /usr/share/keyrings/nvidia-container-toolkit.asc
  echo "deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit.asc] https://nvidia.github.io/libnvidia-container/stable/deb/amd64 /" | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null
  sudo apt-get update
  sudo apt-get install -y nvidia-container-toolkit
  sudo nvidia-ctk runtime configure --runtime=docker
  sudo systemctl restart docker
fi
docker run --rm --gpus all --entrypoint nvidia-smi tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9 -L
```

Assert: that last line prints a `GPU 0:` line naming the card step 1 saw; it pulls 1.6 GB the
first time. A `could not select device driver` failure means Docker has not taken the runtime up:
on Linux run the restart again, on Windows quit Docker Desktop and reopen.

## 3. Layout

```bash
mkdir -p ~/selfhost/tabby/data ~/selfhost/tabby/backups
ls -la ~/selfhost/tabby
```

Assert: `ls -la` shows `data` and `backups`, both owned by the user. No ownership fix is needed:
the container starts as root in its own namespace and sets its data directory to mode 700 on
every start, and on Windows Docker Desktop's file sharing owns it.

## 4. Secrets

One secret: the key Tabby signs session tokens with. Generate it here, print it nowhere, keep it
out of your summary and out of any log line.

```bash
umask 077
cat > ~/selfhost/tabby/.env <<EOF
TABBY_WEBSERVER_JWT_TOKEN_SECRET=$(openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
EOF
chmod 600 ~/selfhost/tabby/.env
umask 022
ls -l ~/selfhost/tabby/.env
```

Assert: the file exists with mode `-rw-------`. Git Bash ships openssl and sed, so this runs the
same on both systems. The 8-4-4-4-12 shape matters: Tabby exits rather than warning if the value
is not a UUID, and without the variable it invents a new key on every start, signing the user out
whenever Docker restarts. On Windows those mode bits are advisory: NTFS does not enforce them,
and the real boundary is the user's own Windows account.

## 5. compose.yml

```bash
cat > ~/selfhost/tabby/compose.yml <<'EOF'
# Tabby · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose ....... https://tabby.tabbyml.com/docs/quick-start/installation/docker-compose/
#   models registry ...... https://tabby.tabbyml.com/docs/models/
#   upgrade .............. https://tabby.tabbyml.com/docs/administration/upgrade/
#
# One service on the computer you are sitting at, and it wants the NVIDIA card
# in that computer. Paths are relative to ~/selfhost/tabby/, so data/ and
# backups/ open in your file manager. The image is built against CUDA 12.4.1
# and is linux/amd64 only, which is why this file has no macOS story. Digests
# read 2026-08-06 on Docker Hub.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  tabby:
    image: tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9
    container_name: tabby
    restart: unless-stopped
    # Upstream's own quick start, plus the default embedding model, which
    # is downloaded alongside these two.
    command: serve --model StarCoder-1B --chat-model Qwen2-1.5B-Instruct --device cuda
    env_file: ./.env
    environment:
      # No anonymous usage reports leave this computer.
      TABBY_DISABLE_USAGE_COLLECTION: "1"
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8134.
      - "127.0.0.1:8134:8080"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # The dashboard answers unauthenticated; the /v1 API does not. The
      # long start period is the 3 GB model download.
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 600s
EOF
cd ~/selfhost/tabby && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount. Tabby checks
each downloaded model against the SHA-256 its registry publishes.

## 6. Nothing is public

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

- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the dashboard works.
- No firewall rule. Nothing is published past loopback.

8134 is bound to 127.0.0.1, this computer only. The user's phone cannot reach it, nor a laptop on
the same wifi, nor anyone on the internet, and the editor that uses it runs here. Confirm:

```bash
grep -n '127.0.0.1' ~/selfhost/tabby/compose.yml
```

Assert: one line, `- "127.0.0.1:8134:8080"`. The llama-server processes take ports above 30888
inside the container's namespace and are published nowhere.

## 7. Start and verify

The first start downloads about 3 GB of models before anything answers. Budget 20 minutes.

```bash
cd ~/selfhost/tabby
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8134/); echo "$i $code"; [ "$code" = 200 ] && break; sleep 20; done
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized isChatEnabled allowSelfSignup } }"}' http://localhost:8134/graphql; echo
docker compose logs --tail 5 tabby
```

Assert all three, and print what you received for each: the loop ends printing `200`; the
GraphQL response contains `"isAdminInitialized":false` and `"isChatEnabled":true`; the log tail
shows the `Listening at` banner. If the loop never reaches 200, stop, run
`docker compose logs --tail 60 tabby`, and name the likely earlier step: a line about
`libcuda.so.1` or `could not select device driver` is step 2, and a process that exits after
saying something about a UUID is step 4. If `port is already allocated` came back, find what holds
8134 (`ss -ltnp | grep 8134`, or `netstat -ano | findstr :8134`) and stop until the user frees it.
A running container is not success.

STOP: tell the user to open http://localhost:8134 and create their account, and wait. Do not
continue until they confirm. A server with no administrator lands on the setup flow, whose first
screen reads `Welcome!` above `Your tabby server is live and ready to use.` with a `Start`
button; the step after is headed `Create Admin Account` and says the password cannot be
recovered. Have them save it as they type.

```bash
curl -sS -H 'Content-Type: application/json' -d '{"query":"{ serverInfo { isAdminInitialized allowSelfSignup } }"}' http://localhost:8134/graphql; echo
```

Assert: `"isAdminInitialized":true` and `"allowSelfSignup":false`. If it still prints `false`, the
account was not created; do not go on. Tell the user the next thing they need is an editor
extension pointed at http://localhost:8134 with a token from the dashboard, and that installing
it is theirs to do.

## 8. First backup and restore

One archive: the accounts database, the compose file and the secret. The models under data/models
are deliberately not in it: 3 GB, re-downloadable, checked on every start.

```bash
cd ~/selfhost/tabby
docker compose stop
tar -C ~/selfhost/tabby -czf ~/selfhost/tabby/backups/tabby-$(date +%F).tar.gz data/ee compose.yml .env
docker compose start
ls -lh ~/selfhost/tabby/backups/
```

Assert: the archive exists and is non-empty. Print its size. The container is stopped because a
SQLite database copied mid-write is not a backup.

That archive sits on the same disk as the data, which is not a backup, and on one computer the
disk and the machine fail together. Ask the user for a destination that leaves this computer, a
sync folder or a USB stick, and copy it there with `cp`. In Git Bash a Windows drive is
`/d/Backups`, not `D:\Backups`. Assert: the user confirms the file is listed there. If they have
none, say plainly that this install has no backup.

To restore: `cd ~/selfhost/tabby`, `docker compose down`, `rm -rf data/ee`, untar the archive
there, then `docker compose up -d`. Accounts and access tokens are in `data/ee/db.sqlite`; the
key that validates existing sessions is in `.env`, and a database restored without it signs
everyone out. Upstream states that Tabby does not support downgrading, so this archive is the
only way back.

## 9. Updating later

New versions are listed at https://github.com/TabbyML/tabby/releases. The Docker tag drops the
leading `v`, so `v0.33.0` is tag `0.33.0`. Take the step 8 backup first, there is no downgrade
path, then edit the image line in compose.yml to the new tag and digest:

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

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

## 10. What will probably go wrong

I rebooted, opened the editor, typed for ten minutes with no completions, and assumed the
extension had lost its token. It had not: Docker Desktop never started with the session, so
nothing was listening on 8134 and every request failed quietly, which is exactly what an absent
code assistant looks like. `restart: unless-stopped` only takes effect once the Docker daemon is
up. Turn on Docker's start-at-login setting, and after a reboot run
`cd ~/selfhost/tabby && docker compose up -d` before concluding anything is wrong.

## 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 switch to the `-cuda11` tag or a community CPU build. The pinned tag is the CUDA 12.4.1
  image upstream publishes, and step 1 already refused a driver too old for it.
- Do not configure SMTP. Leaving it unset is what keeps self-registration off.
- Do not raise `--parallelism` or swap in a larger model. Both multiply the GPU memory needed,
  and step 1 measured the card for the two models in compose.yml.
````

## docker-compose.yml

```yaml
# Tabby · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker compose ....... https://tabby.tabbyml.com/docs/quick-start/installation/docker-compose/
#   docker run ........... https://tabby.tabbyml.com/docs/quick-start/installation/docker/
#   models registry ...... https://tabby.tabbyml.com/docs/models/
#   upgrade .............. https://tabby.tabbyml.com/docs/administration/upgrade/
#
# One service, and it wants an NVIDIA GPU: the image is built from
# docker/Dockerfile.cuda against CUDA 12.4.1, its llama-server links
# libcuda.so.1, and there is no CPU-only build to fall back to. Everything
# Tabby keeps sits under TABBY_ROOT, which the image sets to /data: accounts in
# ee/db.sqlite, the search index, and three model files fetched on first start.
# Digests read on 2026-08-06, same on Docker Hub and ghcr.io; amd64 only.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  tabby:
    image: tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9
    container_name: tabby
    restart: unless-stopped
    # Upstream's own quick start, plus the embedding model the config
    # defaults to, which is downloaded alongside these two.
    command: serve --model StarCoder-1B --chat-model Qwen2-1.5B-Instruct --device cuda
    env_file: /srv/tabby/.env
    environment:
      # No anonymous usage reports leave this box.
      TABBY_DISABLE_USAGE_COLLECTION: "1"
    volumes:
      - /srv/tabby/data:/data
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8134.
      - "127.0.0.1:8134:8080"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # The dashboard answers unauthenticated; the /v1 API does not. The
      # long start period is the 3 GB model download on first boot.
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 600s
```

## compose.local.yml

```yaml
# Tabby · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker compose ....... https://tabby.tabbyml.com/docs/quick-start/installation/docker-compose/
#   models registry ...... https://tabby.tabbyml.com/docs/models/
#   upgrade .............. https://tabby.tabbyml.com/docs/administration/upgrade/
#
# One service on the computer you are sitting at, and it wants the NVIDIA card
# in that computer. Paths are relative to ~/selfhost/tabby/, so data/ and
# backups/ open in your file manager. The image is built against CUDA 12.4.1
# and is linux/amd64 only, which is why this file has no macOS story. Digests
# read 2026-08-06 on Docker Hub.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  tabby:
    image: tabbyml/tabby:0.32.0@sha256:8de9da4d266b5cd0fb54fe1ffaa772311e5d886453ba1c3a0d1bbcee893e71a9
    container_name: tabby
    restart: unless-stopped
    # Upstream's own quick start, plus the default embedding model, which
    # is downloaded alongside these two.
    command: serve --model StarCoder-1B --chat-model Qwen2-1.5B-Instruct --device cuda
    env_file: ./.env
    environment:
      # No anonymous usage reports leave this computer.
      TABBY_DISABLE_USAGE_COLLECTION: "1"
    volumes:
      - ./data:/data
    ports:
      # Loopback only: no other device on the wifi can reach 8134.
      - "127.0.0.1:8134:8080"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # The dashboard answers unauthenticated; the /v1 API does not. The
      # long start period is the 3 GB model download.
      test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 600s
```

## Caddyfile

```text
# Tabby · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://tabby.tabbyml.com/docs/quick-start/installation/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Tabby serves a
# plain HTTP listener on 8080 and terminates nothing itself.

<DOMAIN> {
	# The dashboard bundle and the JSON responses compress well; Caddy's
	# default matcher leaves everything else alone.
	encode zstd gzip

	# Tabby sets no transport or frame headers of its own. HSTS is on
	# because every request here carries a session cookie or an editor's
	# access token.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8134 is the loopback port compose publishes here. It is not a
	# container port and it is not open in the firewall. Caddy flushes the
	# streamed completions as they arrive and upgrades the dashboard's
	# /subscriptions WebSocket with no extra configuration.
	reverse_proxy 127.0.0.1:8134
}
```

## install.sh

```bash
#!/usr/bin/env bash
# Tabby · 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=tabby.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://tabby.tabbyml.com/docs/quick-start/installation/docker-compose/
#   https://tabby.tabbyml.com/docs/quick-start/installation/docker/
#   https://tabby.tabbyml.com/docs/models/
#   https://tabby.tabbyml.com/docs/administration/upgrade/
#   https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
#
# This machine needs an NVIDIA GPU. The published image is built against CUDA
# 12.4.1, the inference process links libcuda.so.1, and there is no CPU-only
# build of it. The script refuses to run without a card and a driver new enough
# for that image, and it does not install a kernel driver: on a rented box that
# comes from the provider's image.
#
# One secret is generated here, on this machine: the key Tabby signs session
# tokens with. It goes into /srv/tabby/.env with mode 600 and is never printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/tabby}"
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. tabby.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"
command -v nvidia-smi >/dev/null 2>&1 || die "nvidia-smi is missing: this host has no NVIDIA driver, and Tabby has no CPU-only image"

arch="$(dpkg --print-architecture)"
[ "$arch" = "amd64" ] || die "the Tabby image publishes linux/amd64 only; this host reports ${arch}"

avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 4096 ] || die "only ${avail_mb} MB of RAM available; this install wants 4096 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 20 ] || die "only ${avail_gb} GB free on /srv; the image and three model files want 20 GB"

vram_mib="$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits | head -1 | tr -dc '0-9')"
[ -n "$vram_mib" ] || die "nvidia-smi reported no GPU"
[ "$vram_mib" -ge 8000 ] || die "the GPU reports ${vram_mib} MiB; the two models plus their context want 8000 MiB"

# nvidia-smi's header prints the highest CUDA version this driver supports. The
# image is built against 12.4.1, so an older driver starts and then dies.
cuda_ver="$(nvidia-smi | awk -F'CUDA Version: ' '/CUDA Version/ {print $2}' | awk '{print $1}')"
cuda_major="${cuda_ver%%.*}"
cuda_minor="${cuda_ver#*.}"
cuda_minor="${cuda_minor%%.*}"
[ -n "$cuda_major" ] || die "could not read a CUDA version out of nvidia-smi"
if [ "$cuda_major" -lt 12 ] || { [ "$cuda_major" -eq 12 ] && [ "$cuda_minor" -lt 4 ]; }; then
	die "the driver reports CUDA ${cuda_ver}; this image needs 12.4 or newer"
fi

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, and give Docker the card --------------------------

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

# The signing key is downloaded to a file and named in the apt source line.
# Nothing here is piped into a shell.
if ! docker info 2>/dev/null | grep -qi nvidia; then
	echo "==> installing the NVIDIA Container Toolkit"
	sudo curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey -o /usr/share/keyrings/nvidia-container-toolkit.asc
	sudo chmod a+r /usr/share/keyrings/nvidia-container-toolkit.asc
	echo "deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit.asc] https://nvidia.github.io/libnvidia-container/stable/deb/amd64 /" \
		| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list >/dev/null
	sudo apt-get update
	sudo apt-get install -y nvidia-container-toolkit
	sudo nvidia-ctk runtime configure --runtime=docker
	sudo systemctl restart docker
fi

image="$(awk -F'image: ' '/image: /{print $2; exit}' "$APP_DIR/compose.yml")"
docker run --rm --gpus all --entrypoint nvidia-smi "$image" -L | grep -q '^GPU 0:' \
	|| die "a container could not see the GPU. Check: docker info | grep -i nvidia"

# --- 3. Generate the one secret, on the server -------------------------------
#
# Tabby exits rather than warning if this value does not parse as a UUID, so the
# sed puts 128 bits of openssl entropy into the 8-4-4-4-12 shape. Read it later
# with
#   sudo grep TABBY_WEBSERVER /srv/tabby/.env

if [ ! -f "$APP_DIR/.env" ]; then
	umask 077
	cat > "$APP_DIR/.env" <<-ENVFILE
		TABBY_WEBSERVER_JWT_TOKEN_SECRET=$(openssl rand -hex 16 | sed -E 's/(.{8})(.{4})(.{4})(.{4})(.{12})/\1-\2-\3-\4-\5/')
	ENVFILE
	chmod 600 "$APP_DIR/.env"
	umask 022
fi

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

# --- 4. Caddy site block, on the host ----------------------------------------

if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
	sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-tabby"
	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 8134 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; 8134 stays closed"
	sudo ufw allow 80/tcp
	sudo ufw allow 443/tcp
	sudo ufw allow 443/udp
	sudo ufw status verbose
fi

# --- 6. Start it -------------------------------------------------------------
#
# The first start downloads about 3 GB of model files before the listener opens.
# Twenty minutes on a slow link is normal, so the wait loop is long on purpose.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/ (first start downloads ~3 GB of models)"
for _ in $(seq 1 60); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
	[ "$code" = "200" ] && break
	sleep 20
done
[ "${code:-}" = "200" ] || die "the dashboard answered ${code:-nothing}. Check: docker compose logs --tail 60 tabby"

info="$(curl -sS -H 'Content-Type: application/json' \
	-d '{"query":"{ serverInfo { isAdminInitialized isChatEnabled allowSelfSignup } }"}' \
	"https://${DOMAIN_HOST}/graphql" || true)"

# No account exists yet, so the setup form is open and the next visitor claims
# the server. That is why the summary below is about creating it now.
echo "$info" | grep -q '"isAdminInitialized":false' \
	|| die "serverInfo did not report isAdminInitialized:false. Response: ${info}"
echo "$info" | grep -q '"isChatEnabled":true' \
	|| die "the chat model did not load. Check: docker compose logs --tail 60 tabby"
echo "$info" | grep -q '"allowSelfSignup":false' \
	|| die "self-signup is on, which this install does not configure. Stop and investigate."

# --- 7. The first backup, before day one ends --------------------------------
#
# The models under data/models are left out on purpose: 3 GB, re-downloadable,
# and checked against a published SHA-256 on every start.

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

cat <<-DONE

	Tabby is answering at https://${DOMAIN_HOST}

	  1. Nobody owns this server yet. Open https://${DOMAIN_HOST} now and
	     create the administrator account: the setup form is reachable by
	     anyone who loads that hostname until you do. The first screen reads
	     "Welcome!" and the step after it is "Create Admin Account".
	     There is no mail server here, so that password cannot be reset.
	  2. Your session-signing key is in $APP_DIR/.env, mode 600. Read it with
	       sudo grep TABBY_WEBSERVER $APP_DIR/.env
	     and put it in your password manager. It was not printed here.
	  3. Self-registration stays off, because it needs SMTP and an allowed
	     email domain and this install configures neither. A second person
	     gets in by an invitation you send from the dashboard. Your editor
	     connects with a Tabby extension pointed at this server, using a
	     token from that same dashboard.
	  4. First backup written to $APP_DIR/backups: the accounts database, the
	     compose file, the secret and the live Caddy block. The 3 GB of model
	     files are not in it and do not need to be. It is on the same disk as
	     the data, which is not a backup. Copy it somewhere else tonight:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/tabby/
	  5. Upstream does not support downgrading, so take a backup before every
	     version bump. New releases: https://github.com/TabbyML/tabby/releases

DONE
```

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