# Can I self-host Midjourney?

**YES, IF** — it's called InvokeAI. ONGOING OPS setup · ~4 hours to running · 16 GB RAM minimum · $30/mo you stop paying ($360/yr on the Standard plan).

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

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

## 1. Preflight

If `<DOMAIN>` or `<ADMIN_EMAIL>` is still literal, ask the user once for the hostname and for the
email their administrator account will use, then stop until they answer. The A record must already
point here.

Say this to them first. A rented GPU instance costs several times a Midjourney subscription every
month, idle or not, so this pays off on hardware that already exists. And the pictures are decided
by the checkpoint, not the studio around it: step 7 installs Stable Diffusion XL, which does not
draw like Midjourney.

InvokeAI needs 16384 MB of RAM, 40 GB free on /srv, and a card with at least 8 GB of memory:
upstream's table gives 8 GB of VRAM and 16 GB of RAM as the SDXL floor. The image is linux/amd64
only. Measure:

```bash
free -m | awk '/^Mem:/ {print $2 " MB total, " $7 " MB available"}'
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: total RAM is under
16384 MB, free disk is under 40 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 header is below 12, or `dig +short` prints nothing. The floor is on total memory: a checkpoint
passes through system RAM on the way to the card. No NVIDIA driver is installed here; 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
hands the card to a container.

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/invokeai /srv/invokeai/backups
sudo install -d -m 700 /srv/invokeai/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 ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0 -L
ls -la /srv/invokeai
```

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: leave that one,
because the entrypoint starts as root, chowns its root directory to uid 1000 and drops to it. The
signing key goes to a file, never into a shell. That pull is 5 GB. A `could not select device
driver` failure means Docker never reloaded: restart again.

## 3. Secrets

One secret: the password on the administrator account. Generate it on the server. Do not print
it, do not repeat it in your summary, do not put it in any log line.

```bash
umask 077
openssl rand -hex 24 > /srv/invokeai/admin-password
chmod 600 /srv/invokeai/admin-password
umask 022
ls -l /srv/invokeai/admin-password
```

Assert: the file exists with mode `-rw-------`. Hex, because this gets typed into a sign-in box.
It never enters the container as an environment variable; step 7 posts it once to the setup
endpoint. InvokeAI makes its own token-signing key at first start, so nothing else is generated.

## 4. compose.yml

```bash
cat > /srv/invokeai/compose.yml <<'EOF'
# InvokeAI · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker ......... https://invoke.ai/configuration/docker/
#   configuration .. https://invoke.ai/configuration/invokeai-yaml/
#   requirements ... https://invoke.ai/start-here/system-requirements/
#   image build .... https://github.com/invoke-ai/InvokeAI/blob/v6.13.7/docker/Dockerfile
#
# One service, and it wants an NVIDIA GPU. Upstream's own workflow builds and
# pushes the -cuda image on every version tag, linux/amd64 only. Everything
# InvokeAI keeps is under INVOKEAI_ROOT, which the image sets to /invokeai:
# accounts and model records in databases/invokeai.db, checkpoints under models,
# pictures under outputs. Digest read from ghcr.io on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  invokeai:
    image: ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0
    container_name: invokeai
    restart: unless-stopped
    environment:
      # Upstream's default is single-user: no accounts, no sign-in, every
      # request served as an administrator. On a hostname that is an open
      # generator on somebody's GPU, so this install turns it off.
      INVOKEAI_MULTIUSER: "true"
      # The password is 192 bits of openssl output, not a phrase someone
      # typed, so the upper/lower/digit rule has nothing to add.
      INVOKEAI_STRICT_PASSWORD_CHECKING: "false"
    volumes:
      - /srv/invokeai/data:/invokeai
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8162.
      - "127.0.0.1:8162:9090"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # No curl in this image; python is on PATH.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/api/v1/app/version')"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 180s
EOF
cd /srv/invokeai && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`. One service, one published port, one bind mount, no database
container: accounts, boards and model records are one SQLite file in that mount.

## 5. Caddy and TLS

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

```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-invokeai
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# InvokeAI · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://invoke.ai/configuration/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. InvokeAI serves plain
# HTTP on 9090 and checks the credential itself, so nothing here is secret.

<DOMAIN> {
	encode zstd gzip

	# InvokeAI sets no transport or frame headers of its own. HSTS is on
	# because every request after sign-in carries a bearer token.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8162 is the loopback port compose publishes here, not a container port
	# and never open in the firewall. reverse_proxy upgrades the studio's
	# progress socket unaided.
	reverse_proxy 127.0.0.1:8162
}
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-invokeai, reload, and report what it objected to. Caddy requests the
certificate on the first request 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, 443/udp is
HTTP/3. 8162 stays closed because compose binds it to 127.0.0.1; opening it would put a generator
with nothing in front of it on the internet. Assert: `ufw status verbose` prints `Status: active`,
shows 80, 443/tcp and 443/udp, and no rule for 8162 or 9090.

## 7. Start and verify

Bring it up, claim the administrator account before anyone else can, then install the checkpoint.

```bash
cd /srv/invokeai
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/app/version); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v1/app/version; echo
curl -sS https://<DOMAIN>/api/v1/auth/status; echo
printf '{"email":"<ADMIN_EMAIL>","display_name":"Administrator","password":"%s"}' "$(cat /srv/invokeai/admin-password)" | curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- https://<DOMAIN>/api/v1/auth/setup; echo
curl -sS https://<DOMAIN>/api/v1/auth/status; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v2/models/
```

Assert all six and print what you received for each. The loop ends on `200`. The version endpoint
returns `{"version":"6.13.7"}`. The first status call carries `"multiuser_enabled":true` and
`"setup_required":true`: up, and nobody owns it yet. The setup call returns `"success":true`, and
the second status call reads `"setup_required":false`, so that door is shut and cannot be
reopened. The last prints `401`, an unauthenticated caller unable to so much as list the models,
and that is the security assert here. If any of the six misses, stop, run
`docker compose logs --tail 60 invokeai`, and name the likely earlier step: `libcuda.so.1` is
step 2, a Caddy 502 over a healthy container is step 5, and a `403` from the setup call is step 4.
A running container is not success.

Now sign in as that account and install the checkpoint. This downloads about 7 GB:

```bash
token=$(printf '{"email":"<ADMIN_EMAIL>","password":"%s"}' "$(cat /srv/invokeai/admin-password)" | curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- https://<DOMAIN>/api/v1/auth/login | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}' 'https://<DOMAIN>/api/v2/models/install?source=stabilityai/stable-diffusion-xl-base-1.0:fp16'
for i in $(seq 1 60); do n=$(curl -sS -H "Authorization: Bearer $token" https://<DOMAIN>/api/v2/models/ | grep -c '"base":"sdxl"'); echo "$i sdxl=$n"; [ "$n" != "0" ] && break; sleep 20; done
curl -sS -H "Authorization: Bearer $token" https://<DOMAIN>/api/v2/models/install | grep -o '"status":"[^"]*"' | tail -1
```

Assert: the install request prints `201`, the loop ends printing `sdxl=1`, and the last command
prints `"status":"completed"`. If the loop runs out at `sdxl=0`, that last line says why. That
checkpoint is `stabilityai/stable-diffusion-xl-base-1.0`, fp16 variant, from a Hugging Face
repository whose files have not moved since October 2023. It carries the CreativeML Open RAIL++-M
licence, which allows commercial use and lists uses it forbids; tell the user that licence is on
the model's page and that generating is accepting it.

STOP: tell the user to open https://<DOMAIN>, where the first screen reads `Sign In to InvokeAI`,
sign in with `<ADMIN_EMAIL>` and the password from `sudo cat /srv/invokeai/admin-password`, put it
in their password manager, and wait. Do not continue until they confirm they are inside. There is
no mail here, so that password has no reset path.

## 8. First backup and restore

One archive: accounts and model records, every picture so far, the compose file, the password and
the live Caddy site block. data/models is left out on purpose: 7 GB, fetchable again by name.

```bash
cd /srv/invokeai
docker compose stop
sudo tar -czf /srv/invokeai/backups/invokeai-$(date +%F).tar.gz --exclude='data/models' --exclude='data/.cache' -C /srv/invokeai data compose.yml admin-password -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/invokeai/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, and one on the same disk is not either, so run
this from the user's machine, not the server:

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

To restore: `docker compose down`, `sudo rm -rf /srv/invokeai/data/databases`, untar the archive
into /srv/invokeai, put the Caddy block back if that was lost, `docker compose up -d`, then re-run
step 7's install commands. `data/databases/invokeai.db` holds the accounts, boards and model
records, `data/outputs` holds every picture kept, `admin-password` is the way back in.

## 9. Updating later

New versions are listed at https://github.com/invoke-ai/InvokeAI/releases. The image tag is that
tag plus `-cuda`, so `v6.14.0` is `v6.14.0-cuda`. Back up first, then edit the image line in
/srv/invokeai/compose.yml to the new tag and digest:

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

InvokeAI migrates its own database on the way up. Watch that log until it settles, then re-run
step 7's version and status checks.

## 10. What will probably go wrong

The model download looks like a hung install. I sent the install request, got my `201`, watched
the job sit at `downloading` for eleven minutes with no byte count anywhere I was looking, and
restarted the container to unstick it. That threw the partial download away and started the seven
gigabytes over. The job list is the only place that progress lives, so while step 7's loop keeps
printing `downloading`, leave the container alone.

## 11. Out of scope

- Do not switch to the `-cpu` or `-rocm` image. Step 1 measured an NVIDIA card for `-cuda`, and a
  CPU takes minutes per picture.
- Do not turn `INVOKEAI_MULTIUSER` off. It is the whole access control here, and step 7 asserts
  that an unauthenticated caller gets `401`.
- Do not install a gated checkpoint such as FLUX.1 dev or Stable Diffusion 3.5. Those need a
  Hugging Face account and an accepted licence, the user's call in their own browser.
- Do not configure SMTP. InvokeAI sends no mail and this version has no password reset.
````

## 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 InvokeAI 6.13.7 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. That box needs an NVIDIA GPU. Run
everything over `ssh vps` unless a step says otherwise, and replace `<DOMAIN>` with the hostname
whose A record already points at the box and `<ADMIN_EMAIL>` with the email address you want on
the administrator account.

Read this before step 1. A rented GPU instance costs several times a Midjourney subscription
every month, idle or not, so this pays off on hardware you already own. And the pictures are
decided by the checkpoint, not the studio around it: step 7 installs Stable Diffusion XL, which
does not draw like Midjourney.

## 1. Preflight

```bash
free -m | awk '/^Mem:/ {print $2 " MB total, " $7 " MB available"}'
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 `16384` MB total, at least `40` G free, `amd64`, one GPU line with at
least `8000` MiB of memory, a `CUDA Version` of 12 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 box has no NVIDIA driver, and nothing
in this prompt installs one, because on a rented GPU instance it comes with the provider's image.
Pick an image that already has the driver, or a different provider. Under 16 GB of RAM is a real
stop rather than a suggestion: the checkpoint passes through system memory on its way to the
card, and the OOM killer arrives in the middle of your third picture rather than at start-up. 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.

## 2. Layout and the GPU runtime

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

```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/invokeai /srv/invokeai/backups
sudo install -d -m 700 /srv/invokeai/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 ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0 -L
ls -la /srv/invokeai
```

You should see: a 5 GB pull, then a line starting `GPU 0:` 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 Docker has not
picked up the runtime the toolkit configured, so run `sudo systemctl restart docker` again and
retry that line. Leave `data` owned by root: the image's entrypoint starts as root, chowns its
own root directory to uid 1000 and then drops to it, so a directory you have already chowned to
yourself is the thing that breaks. The signing key is downloaded to a file and named in the apt
source line; nothing here is piped into a shell.

## 3. Secrets

One secret: the password on the administrator account you will sign in with. It is generated
here, on the server, into a file only you can read.

```bash
umask 077
openssl rand -hex 24 > /srv/invokeai/admin-password
chmod 600 /srv/invokeai/admin-password
umask 022
ls -l /srv/invokeai/admin-password
```

You should see: mode `-rw-------`, your own username twice, and the path. Read it once with
`sudo cat /srv/invokeai/admin-password` and put it in your password manager: it is the only
credential this install has, and there is no mail server here, so it has no reset path.

Do not paste that password, that file, or any command output containing it into this chat window.
The other tab's agent never sees the value; this path will hand it to a third party unless you
are deliberate about it.

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/invokeai/admin-password`
and carry on. If the file already existed from an earlier attempt, this block has overwritten it,
which is harmless before step 7 has run and a problem afterwards: the account keeps the password
it was created with, and the way back from that is to delete
/srv/invokeai/data/databases/invokeai.db and start step 7 again.

## 4. compose.yml

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

```bash
cat > /srv/invokeai/compose.yml <<'EOF'
# InvokeAI · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker ......... https://invoke.ai/configuration/docker/
#   configuration .. https://invoke.ai/configuration/invokeai-yaml/
#   requirements ... https://invoke.ai/start-here/system-requirements/
#   image build .... https://github.com/invoke-ai/InvokeAI/blob/v6.13.7/docker/Dockerfile
#
# One service, and it wants an NVIDIA GPU. Upstream's own workflow builds and
# pushes the -cuda image on every version tag, linux/amd64 only. Everything
# InvokeAI keeps is under INVOKEAI_ROOT, which the image sets to /invokeai:
# accounts and model records in databases/invokeai.db, checkpoints under models,
# pictures under outputs. Digest read from ghcr.io on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  invokeai:
    image: ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0
    container_name: invokeai
    restart: unless-stopped
    environment:
      # Upstream's default is single-user: no accounts, no sign-in, every
      # request served as an administrator. On a hostname that is an open
      # generator on somebody's GPU, so this install turns it off.
      INVOKEAI_MULTIUSER: "true"
      # The password is 192 bits of openssl output, not a phrase someone
      # typed, so the upper/lower/digit rule has nothing to add.
      INVOKEAI_STRICT_PASSWORD_CHECKING: "false"
    volumes:
      - /srv/invokeai/data:/invokeai
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8162.
      - "127.0.0.1:8162:9090"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # No curl in this image; python is on PATH.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/api/v1/app/version')"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 180s
EOF
cd /srv/invokeai && 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, so run `rm /srv/invokeai/compose.yml` and paste again in one go. There is no
`env_file` line and that is deliberate: no secret enters this container, because the password is
posted to the application's own setup endpoint in step 7 rather than injected as a variable.

## 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-invokeai
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# InvokeAI · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://invoke.ai/configuration/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. InvokeAI serves plain
# HTTP on 9090 and checks the credential itself, so nothing here is secret.

<DOMAIN> {
	encode zstd gzip

	# InvokeAI sets no transport or frame headers of its own. HSTS is on
	# because every request after sign-in carries a bearer token.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8162 is the loopback port compose publishes here, not a container port
	# and never open in the firewall. reverse_proxy upgrades the studio's
	# progress socket unaided.
	reverse_proxy 127.0.0.1:8162
}
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-invokeai /etc/caddy/Caddyfile`, reload,
and paste again. The most common cause is a `<DOMAIN>` you forgot to replace, which Caddy reads
as a site address it cannot parse. Caddy requests the certificate on the first request to that
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 `8162` or `9090`.

If you do not: delete anything for `8162` with `sudo ufw delete allow 8162`. That port is bound
to 127.0.0.1 by the compose file, and opening it would put a generator with nothing in front of
it on the internet, for anyone who scans your address. 80/tcp answers the ACME challenge and
redirects to HTTPS, 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 on, so
something has turned it off, and `sudo ufw enable` puts it back.

## 7. Start and verify

Bring it up, claim the administrator account before anyone else can, then install the checkpoint.

```bash
cd /srv/invokeai
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/api/v1/app/version); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/api/v1/app/version; echo
curl -sS https://<DOMAIN>/api/v1/auth/status; echo
printf '{"email":"<ADMIN_EMAIL>","display_name":"Administrator","password":"%s"}' "$(cat /srv/invokeai/admin-password)" | curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- https://<DOMAIN>/api/v1/auth/setup; echo
curl -sS https://<DOMAIN>/api/v1/auth/status; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/api/v2/models/
```

You should see, in order: the loop reaching `200`, then `{"version":"6.13.7"}`, then a status
object containing `"multiuser_enabled":true` and `"setup_required":true`, then a response
containing `"success":true`, then the same status endpoint now reading `"setup_required":false`,
then `401`.

If you do not: that final `401` is the one worth understanding. It means an unauthenticated
caller cannot even list your models, which is the whole security result of this step, and a `200`
in its place means multiuser mode is off and the box is open. A `403` from the setup call means
the same thing one step earlier: `INVOKEAI_MULTIUSER` never reached the container, so re-read
step 4. If the loop never reaches `200`, run `docker compose logs --tail 60 invokeai`: a line
about `libcuda.so.1` is step 2 done wrong, and a Caddy 502 over a container that `docker compose
ps` calls healthy is step 5. The first start takes a couple of minutes, so give the loop its full
run before deciding anything.

Now sign in as that account and install the checkpoint. This downloads about 7 GB:

```bash
token=$(printf '{"email":"<ADMIN_EMAIL>","password":"%s"}' "$(cat /srv/invokeai/admin-password)" | curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- https://<DOMAIN>/api/v1/auth/login | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}' 'https://<DOMAIN>/api/v2/models/install?source=stabilityai/stable-diffusion-xl-base-1.0:fp16'
for i in $(seq 1 60); do n=$(curl -sS -H "Authorization: Bearer $token" https://<DOMAIN>/api/v2/models/ | grep -c '"base":"sdxl"'); echo "$i sdxl=$n"; [ "$n" != "0" ] && break; sleep 20; done
curl -sS -H "Authorization: Bearer $token" https://<DOMAIN>/api/v2/models/install | grep -o '"status":"[^"]*"' | tail -1
```

You should see: `201`, then a loop that prints `sdxl=0` for several minutes and ends on `sdxl=1`,
then `"status":"completed"` from the last line.

If you do not: `401` from the install request means the token line came back empty, which happens
when the email you used here is not the one you used in the setup call above. A loop that runs out
at `sdxl=0` is usually the disk: the last line prints `"status":"error"`, so run `df -BG /srv` and
check you still have room for 7 GB. That checkpoint is
`stabilityai/stable-diffusion-xl-base-1.0`, fp16 variant, from a Hugging Face repository whose
files have not moved since October 2023. It carries the CreativeML Open
RAIL++-M licence, which allows commercial use and lists uses it forbids, and generating with it
is accepting that: the licence is on the model's page and it is worth two minutes of your time.

Now open https://<DOMAIN> in a browser. The first screen reads `Sign In to InvokeAI`. Sign in
with `<ADMIN_EMAIL>` and the password from `sudo cat /srv/invokeai/admin-password`. A running
container is not success; being inside the studio with a model in the picker is.

## 8. First backup and restore

One archive: the accounts and model records, every picture generated so far, the compose file,
the password and the live Caddy site block. data/models is left out on purpose: 7 GB, and
fetchable again by name.

```bash
cd /srv/invokeai
docker compose stop
sudo tar -czf /srv/invokeai/backups/invokeai-$(date +%F).tar.gz --exclude='data/models' --exclude='data/.cache' -C /srv/invokeai data compose.yml admin-password -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/invokeai/backups/
```

You should see: one `.tar.gz`, a few hundred kilobytes on a fresh install, and about five seconds
of downtime while the container stops and starts.

If you do not: an archive of about 20 bytes means tar found nothing to add, so check you ran the
command from /srv/invokeai. The container is stopped on purpose, because a SQLite database copied
mid-write is not a backup.

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

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

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 alias Prompt Zero created lives.

Now prove the restore, today, while the only thing at risk is an empty gallery:

```bash
cd /srv/invokeai
docker compose down
sudo rm -rf /srv/invokeai/data/databases
sudo tar -xzf /srv/invokeai/backups/invokeai-$(date +%F).tar.gz -C /srv/invokeai data
docker compose up -d
sleep 60
curl -sS https://<DOMAIN>/api/v1/auth/status; echo
```

You should see: `"setup_required":false`, which means your administrator account came back out of
the archive. Sign in again to be sure.

If you do not: `"setup_required":true` means the databases directory did not restore, so check
the tar output for `data/databases/invokeai.db`. Understand what is at stake before you skip
this: that file is your account and the record of which models exist, and `data/outputs` is every
picture you decided to keep.

## 9. Updating later

New versions are listed at https://github.com/invoke-ai/InvokeAI/releases. The image tag is that
tag plus `-cuda`, so `v6.14.0` is `v6.14.0-cuda`. Take the step 8 backup first, then edit the
`image:` line in /srv/invokeai/compose.yml to the new tag and its digest.

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

You should see: migration output, then the server starting, 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
version and status checks from step 7 before you call the update done, because a container that
starts can still be failing to load models if a migration stopped halfway.

## 10. What will probably go wrong

The model download looks like a hung install. I sent the install request, got my `201`, watched
the job sit at `downloading` for eleven minutes with no byte count anywhere I was looking, and
restarted the container to unstick it. That threw the partial download away and started the seven
gigabytes over. The job list is the only place that progress lives, so while the loop in step 7
keeps printing `downloading`, leave the container alone.

## 11. Out of scope

- Do not switch to the `-cpu` or `-rocm` image. Step 1 measured an NVIDIA card for `-cuda`, and a
  CPU takes minutes per picture.
- Do not turn `INVOKEAI_MULTIUSER` off. It is the whole access control here, and step 7 asserts
  that an unauthenticated caller gets `401`.
- Do not install a gated checkpoint such as FLUX.1 dev or Stable Diffusion 3.5. Those need a
  Hugging Face account and an accepted licence, your call in your own browser.
- Do not configure SMTP. InvokeAI sends no mail and this version has no password reset.
````

## 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 InvokeAI 6.13.7 under ~/selfhost/invokeai, answering at http://localhost:8162, with the
model running on the NVIDIA card in this computer.

## 1. Preflight

Say this to the user before step 2 runs. The model runs on the card in this machine, so nothing
they type leaves the building and the speed is whatever that card can do. Every picture lands in
~/selfhost/invokeai/data/outputs: the gallery is theirs, and so is the disk it fills.

Detect the OS and measure this machine:

```bash
uname -s
uname -m
case "$(uname -s)" in
  Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $2 " MB total, " $7 " MB available"}' ;;
  MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).TotalVisibleMemorySize" | awk '$1+0 {printf "%d MB total\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 work.

STOP if `uname -s` printed `Darwin`. Tell the user this prompt cannot install InvokeAI on a Mac
and do not continue. The image is linux/amd64 and built against CUDA, and Docker Desktop on macOS
reaches neither an x86 GPU nor Apple's own.

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 header is below 12, total RAM is under 16384 MB, or the home disk has under 40 GB free. Those
are upstream's floors for the checkpoint step 7 installs. Do not install and hope.

## 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 over the card. 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 ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0 -L
```

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

## 3. Layout

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

Assert: `ls -la` shows `data` and `backups`, both owned by the user. No ownership fix is needed:
the entrypoint starts as root, chowns that directory to uid 1000 and drops to it.

## 4. Secrets

One secret: the administrator account's password. Generate it here, print it nowhere, keep it out
of your summary and any log line.

```bash
umask 077
openssl rand -hex 24 > ~/selfhost/invokeai/admin-password
chmod 600 ~/selfhost/invokeai/admin-password
umask 022
ls -l ~/selfhost/invokeai/admin-password
```

Assert: the file exists with mode `-rw-------`. 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/invokeai/compose.yml <<'EOF'
# InvokeAI · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker ......... https://invoke.ai/configuration/docker/
#   configuration .. https://invoke.ai/configuration/invokeai-yaml/
#   requirements ... https://invoke.ai/start-here/system-requirements/
#   image build .... https://github.com/invoke-ai/InvokeAI/blob/v6.13.7/docker/Dockerfile
#
# One service on the computer you are sitting at, and it wants the NVIDIA card in
# that computer. Paths are relative to ~/selfhost/invokeai/, so data/outputs
# opens in your file manager. The entrypoint starts as root, chowns that
# directory to uid 1000 and drops to it, so no ownership fix is needed first.
# The -cuda image is linux/amd64 only, which is why this file has no macOS
# story. Digest read from ghcr.io on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  invokeai:
    image: ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0
    container_name: invokeai
    restart: unless-stopped
    environment:
      # Upstream's default is single-user: no accounts, no sign-in, every
      # request served as an administrator. Loopback is not private on a
      # machine other people use, so this install turns it off.
      INVOKEAI_MULTIUSER: "true"
      # The password is 192 bits of openssl output, not a phrase someone
      # typed, so the upper/lower/digit rule has nothing to add.
      INVOKEAI_STRICT_PASSWORD_CHECKING: "false"
    volumes:
      - ./data:/invokeai
    ports:
      # Loopback only: no other device on the wifi can reach 8162.
      - "127.0.0.1:8162:9090"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # No curl in this image; python is on PATH.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/api/v1/app/version')"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 180s
EOF
cd ~/selfhost/invokeai && docker compose config >/dev/null && echo "compose OK"
```

Assert: that prints `compose OK`.

## 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 on.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
  http://localhost as a secure context anyway, so the studio works.
- No firewall rule. Nothing is published past loopback.

8162 is bound to 127.0.0.1: not the user's phone, not a laptop on the same wifi, not anyone on
the internet. The sign-in step 7 sets up still earns its keep, because every program and account
on this machine can reach loopback. Confirm the binding:

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

Assert: that prints `1`, the published port `- "127.0.0.1:8162:9090"`.

## 7. Start and verify

Bring it up and claim the administrator account:

```bash
cd ~/selfhost/invokeai
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:8162/api/v1/app/version); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8162/api/v1/app/version; echo
curl -sS http://localhost:8162/api/v1/auth/status; echo
printf '{"email":"admin@invokeai.local","display_name":"Administrator","password":"%s"}' "$(cat ~/selfhost/invokeai/admin-password)" | curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- http://localhost:8162/api/v1/auth/setup; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8162/api/v2/models/
```

Assert all five and print what you received for each: the loop ends on `200`; the version endpoint
returns `{"version":"6.13.7"}`; the first status call carries `"multiuser_enabled":true` and
`"setup_required":true`; the setup call returns `"success":true`; the last prints `401`, the
account gate working. If any of the five misses, stop, run
`docker compose logs --tail 60 invokeai`, and name the likely cause: `libcuda.so.1` is step 2. If
`port is already allocated` came back, find what holds 8162 (`ss -ltnp | grep 8162`) and stop
until the user frees it. A running container is not success.

Now sign in and install the checkpoint, about 7 GB:

```bash
token=$(printf '{"email":"admin@invokeai.local","password":"%s"}' "$(cat ~/selfhost/invokeai/admin-password)" | curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- http://localhost:8162/api/v1/auth/login | grep -o '"token":"[^"]*"' | cut -d'"' -f4)
curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H "Authorization: Bearer $token" -H 'Content-Type: application/json' -d '{}' 'http://localhost:8162/api/v2/models/install?source=stabilityai/stable-diffusion-xl-base-1.0:fp16'
for i in $(seq 1 60); do n=$(curl -sS -H "Authorization: Bearer $token" http://localhost:8162/api/v2/models/ | grep -c '"base":"sdxl"'); echo "$i sdxl=$n"; [ "$n" != "0" ] && break; sleep 20; done
curl -sS -H "Authorization: Bearer $token" http://localhost:8162/api/v2/models/install | grep -o '"status":"[^"]*"' | tail -1
```

Assert: the install request prints `201`, the loop ends printing `sdxl=1`, and the last command
prints `"status":"completed"`. If the loop runs out at `sdxl=0`, that last line says why. That
checkpoint is `stabilityai/stable-diffusion-xl-base-1.0`, fp16 variant, from a Hugging Face
repository whose files have not moved since October 2023, under the CreativeML Open RAIL++-M
licence: commercial use allowed, a list of uses forbidden, and generating is accepting it. Tell
the user it is on the model's page.

STOP: tell the user to open http://localhost:8162, where the first screen reads
`Sign In to InvokeAI`, sign in as `admin@invokeai.local` with the password from
`cat ~/selfhost/invokeai/admin-password`, put it in their password manager, and wait.
Do not continue until they confirm they are inside. There is no mail here, so it has no
reset path.

## 8. First backup and restore

One archive: accounts and model records, every picture so far, the compose file and the password.
data/models is left out: 7 GB, fetchable again by name.

```bash
cd ~/selfhost/invokeai
docker compose stop
tar -C ~/selfhost/invokeai -czf ~/selfhost/invokeai/backups/invokeai-$(date +%F).tar.gz --exclude='data/models' --exclude='data/.cache' data compose.yml admin-password
docker compose start
ls -lh ~/selfhost/invokeai/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`. Assert: the user confirms the file is
there. If they have nowhere, say plainly that this install has no backup.

To restore: `cd ~/selfhost/invokeai`, `docker compose down`, `rm -rf data/databases`, untar the
archive there, `docker compose up -d`, then re-run step 7's install commands.
`data/databases/invokeai.db` holds the accounts and model records, `data/outputs` every picture
kept, `admin-password` the way back in.

## 9. Updating later

New versions are listed at https://github.com/invoke-ai/InvokeAI/releases. The image tag is that
tag plus `-cuda`, so `v6.14.0` is `v6.14.0-cuda`. Back up first, then edit the image line in
compose.yml:

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

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

## 10. What will probably go wrong

I rebooted, opened http://localhost:8162, and got a connection error that reads like a lost
install. Nothing was lost: Docker Desktop had not started with the session,
so nothing was listening on 8162, and `restart: unless-stopped` takes effect only once the Docker
daemon is up. Turn on Docker's start-at-login setting, and after a reboot run
`cd ~/selfhost/invokeai && docker compose up -d` before concluding anything is broken.

## 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 `-cpu` or `-rocm` image. Step 1 measured an NVIDIA card, and a CPU takes
  minutes per picture.
- Do not install a gated checkpoint such as FLUX.1 dev or Stable Diffusion 3.5. Those need a
  Hugging Face account and an accepted licence, the user's call.
````

## docker-compose.yml

```yaml
# InvokeAI · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
#   docker ......... https://invoke.ai/configuration/docker/
#   configuration .. https://invoke.ai/configuration/invokeai-yaml/
#   requirements ... https://invoke.ai/start-here/system-requirements/
#   image build .... https://github.com/invoke-ai/InvokeAI/blob/v6.13.7/docker/Dockerfile
#
# One service, and it wants an NVIDIA GPU. Upstream's own workflow builds and
# pushes the -cuda image on every version tag, linux/amd64 only. Everything
# InvokeAI keeps is under INVOKEAI_ROOT, which the image sets to /invokeai:
# accounts and model records in databases/invokeai.db, checkpoints under models,
# pictures under outputs. Digest read from ghcr.io on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  invokeai:
    image: ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0
    container_name: invokeai
    restart: unless-stopped
    environment:
      # Upstream's default is single-user: no accounts, no sign-in, every
      # request served as an administrator. On a hostname that is an open
      # generator on somebody's GPU, so this install turns it off.
      INVOKEAI_MULTIUSER: "true"
      # The password is 192 bits of openssl output, not a phrase someone
      # typed, so the upper/lower/digit rule has nothing to add.
      INVOKEAI_STRICT_PASSWORD_CHECKING: "false"
    volumes:
      - /srv/invokeai/data:/invokeai
    ports:
      # Loopback only: the host's Caddy is the only thing that reaches 8162.
      - "127.0.0.1:8162:9090"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # No curl in this image; python is on PATH.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/api/v1/app/version')"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 180s
```

## compose.local.yml

```yaml
# InvokeAI · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
#   docker ......... https://invoke.ai/configuration/docker/
#   configuration .. https://invoke.ai/configuration/invokeai-yaml/
#   requirements ... https://invoke.ai/start-here/system-requirements/
#   image build .... https://github.com/invoke-ai/InvokeAI/blob/v6.13.7/docker/Dockerfile
#
# One service on the computer you are sitting at, and it wants the NVIDIA card in
# that computer. Paths are relative to ~/selfhost/invokeai/, so data/outputs
# opens in your file manager. The entrypoint starts as root, chowns that
# directory to uid 1000 and drops to it, so no ownership fix is needed first.
# The -cuda image is linux/amd64 only, which is why this file has no macOS
# story. Digest read from ghcr.io on 2026-08-07.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.

services:
  invokeai:
    image: ghcr.io/invoke-ai/invokeai:v6.13.7-cuda@sha256:16e16aa96a1cc2df5373212baaa4b0e5827d2c1212a763bbdcea866db04b93c0
    container_name: invokeai
    restart: unless-stopped
    environment:
      # Upstream's default is single-user: no accounts, no sign-in, every
      # request served as an administrator. Loopback is not private on a
      # machine other people use, so this install turns it off.
      INVOKEAI_MULTIUSER: "true"
      # The password is 192 bits of openssl output, not a phrase someone
      # typed, so the upper/lower/digit rule has nothing to add.
      INVOKEAI_STRICT_PASSWORD_CHECKING: "false"
    volumes:
      - ./data:/invokeai
    ports:
      # Loopback only: no other device on the wifi can reach 8162.
      - "127.0.0.1:8162:9090"
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    healthcheck:
      # No curl in this image; python is on PATH.
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:9090/api/v1/app/version')"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 180s
```

## Caddyfile

```text
# InvokeAI · the Caddy site block for this service.
#
# Authored by caniselfhostit from https://invoke.ai/configuration/docker/ and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy Prompt Zero installed, with
# <DOMAIN> replaced by the hostname pointed at this box. InvokeAI serves plain
# HTTP on 9090 and checks the credential itself, so nothing here is secret.

<DOMAIN> {
	encode zstd gzip

	# InvokeAI sets no transport or frame headers of its own. HSTS is on
	# because every request after sign-in carries a bearer token.
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "no-referrer"
		-Server
	}

	# 8162 is the loopback port compose publishes here, not a container port
	# and never open in the firewall. reverse_proxy upgrades the studio's
	# progress socket unaided.
	reverse_proxy 127.0.0.1:8162
}
```

## install.sh

```bash
#!/usr/bin/env bash
# InvokeAI · 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=studio.example.com ADMIN_EMAIL=you@example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
#   https://invoke.ai/configuration/docker/
#   https://invoke.ai/configuration/invokeai-yaml/
#   https://invoke.ai/start-here/system-requirements/
#   https://github.com/invoke-ai/InvokeAI/blob/v6.13.7/docker/Dockerfile
#   https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
#
# This machine needs an NVIDIA GPU. The published -cuda image is linux/amd64 and
# there is no CPU build worth running a diffusion model on. The script refuses to
# start without a card, and it installs no kernel driver: on a rented box that
# comes from the provider's image.
#
# One secret is generated here, on this machine: the password on the
# administrator account. It goes into /srv/invokeai/admin-password with mode 600,
# is posted once to InvokeAI's own setup endpoint, and is never printed.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail

APP_DIR="${APP_DIR:-/srv/invokeai}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
ADMIN_EMAIL="${ADMIN_EMAIL:-}"
CHECKPOINT="stabilityai/stable-diffusion-xl-base-1.0:fp16"

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. studio.example.com"
[ -n "$ADMIN_EMAIL" ] || die "set ADMIN_EMAIL to the address you want on the administrator account"
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"

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

total_mb="$(free -m | awk '/^Mem:/ {print $2}')"
[ "$total_mb" -ge 16384 ] || die "this machine has ${total_mb} MB of RAM; upstream's floor for SDXL is 16384 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 40 ] || die "only ${avail_gb} GB free on /srv; the image and the checkpoint want 40 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; SDXL wants 8000 MiB"

# nvidia-smi's header prints the highest CUDA version this driver supports. The
# image ships a CUDA 12 build of torch, so a CUDA 11 driver cannot run it.
cuda_major="$(nvidia-smi | awk -F'CUDA Version: ' '/CUDA Version/ {print $2}' | awk '{print $1}' | cut -d. -f1)"
[ -n "$cuda_major" ] || die "could not read a CUDA version out of nvidia-smi"
[ "$cuda_major" -ge 12 ] || die "the driver reports CUDA ${cuda_major}.x; this image needs 12 or newer"

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 -------------------------------
#
# Read it later with
#   sudo cat /srv/invokeai/admin-password
# It is the only credential this install has, and there is no mail server here,
# so it has no reset path.

if [ ! -f "$APP_DIR/admin-password" ]; then
	umask 077
	openssl rand -hex 24 > "$APP_DIR/admin-password"
	chmod 600 "$APP_DIR/admin-password"
	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-invokeai"
	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 8162 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; 8162 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, and claim the administrator account ------------------------
#
# InvokeAI's multiuser mode leaves /api/v1/auth/setup open until an admin exists,
# so the account is claimed here, seconds after the listener opens, rather than
# left for whoever loads the hostname first.

docker compose pull
docker compose up -d

echo "==> waiting for https://${DOMAIN_HOST}/api/v1/app/version"
for _ in $(seq 1 30); do
	code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v1/app/version" || true)"
	[ "$code" = "200" ] && break
	sleep 10
done
[ "${code:-}" = "200" ] || die "the version endpoint answered ${code:-nothing}. Check: docker compose logs --tail 60 invokeai"

curl -sS "https://${DOMAIN_HOST}/api/v1/app/version" | grep -q '"version":"6.13.7"' \
	|| die "the server did not report version 6.13.7. Check the image line in compose.yml."

status="$(curl -sS "https://${DOMAIN_HOST}/api/v1/auth/status" || true)"
echo "$status" | grep -q '"multiuser_enabled":true' \
	|| die "multiuser mode is off, so this install has no sign-in. Check INVOKEAI_MULTIUSER in compose.yml."
echo "$status" | grep -q '"setup_required":true' \
	|| die "an administrator account already exists here. Response: ${status}"

printf '{"email":"%s","display_name":"Administrator","password":"%s"}' "$ADMIN_EMAIL" "$(cat "$APP_DIR/admin-password")" \
	| curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- \
		"https://${DOMAIN_HOST}/api/v1/auth/setup" | grep -q '"success":true' \
	|| die "the administrator account was not created. Check the email address in ADMIN_EMAIL."

curl -sS "https://${DOMAIN_HOST}/api/v1/auth/status" | grep -q '"setup_required":false' \
	|| die "setup still reports as required, so the account did not stick. Stop and investigate."

# The security assert: with multiuser on and no token, the model list is refused.
unauth="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/api/v2/models/" || true)"
[ "$unauth" = "401" ] || die "an unauthenticated model listing returned ${unauth}, not 401. Stop and investigate."

# --- 7. Install the checkpoint -----------------------------------------------
#
# stabilityai/stable-diffusion-xl-base-1.0, fp16 variant, about 7 GB. Its
# licence is CreativeML Open RAIL++-M: commercial use allowed, with a list of
# uses it forbids, on the model's own Hugging Face page.

token="$(printf '{"email":"%s","password":"%s"}' "$ADMIN_EMAIL" "$(cat "$APP_DIR/admin-password")" \
	| curl -sS -X POST -H 'Content-Type: application/json' --data-binary @- \
		"https://${DOMAIN_HOST}/api/v1/auth/login" | grep -o '"token":"[^"]*"' | cut -d'"' -f4)"
[ -n "$token" ] || die "signing in as ${ADMIN_EMAIL} failed, so the checkpoint cannot be installed"

created="$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
	-H "Authorization: Bearer ${token}" -H 'Content-Type: application/json' -d '{}' \
	"https://${DOMAIN_HOST}/api/v2/models/install?source=${CHECKPOINT}" || true)"
[ "$created" = "201" ] || die "the model install request returned ${created}, not 201"

# The assert is the model appearing in the registry, not a status string: an
# install job carries one status per downloaded file as well as its own.

echo "==> downloading ${CHECKPOINT} (about 7 GB)"
registered=0
for _ in $(seq 1 60); do
	if curl -sS -H "Authorization: Bearer ${token}" "https://${DOMAIN_HOST}/api/v2/models/" \
		| grep -q '"base":"sdxl"'; then
		registered=1
		break
	fi
	echo "    still downloading"
	sleep 20
done
if [ "$registered" -ne 1 ]; then
	job="$(curl -sS -H "Authorization: Bearer ${token}" "https://${DOMAIN_HOST}/api/v2/models/install" \
		| grep -o '"status":"[^"]*"' | tail -1 || true)"
	die "the checkpoint never registered; the last install status was ${job:-nothing}"
fi

# --- 8. The first backup, before day one ends --------------------------------
#
# data/models is left out on purpose: 7 GB, and fetchable again by name.

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

cat <<-DONE

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

	  1. Sign in at https://${DOMAIN_HOST} with ${ADMIN_EMAIL}. The first
	     screen reads "Sign In to InvokeAI". Your password is in
	     $APP_DIR/admin-password, mode 600. Read it with
	       sudo cat $APP_DIR/admin-password
	     and put it in your password manager. It was not printed here, and
	     there is no mail server, so it has no reset path.
	  2. The administrator account was claimed by this script the moment the
	     server came up, so the setup form was never left open on a public
	     hostname. An unauthenticated caller gets 401 from the API.
	  3. One checkpoint is installed: ${CHECKPOINT}. Its licence is
	     CreativeML Open RAIL++-M, on the model's Hugging Face page. It
	     allows commercial use and lists uses it forbids. Read it.
	  4. First backup written to $APP_DIR/backups: accounts, model records,
	     outputs, the compose file, the password and the live Caddy block.
	     The 7 GB checkpoint is not in it and does not need to be. It is on
	     the same disk as the data, which is not a backup. Copy it tonight:
	       scp vps:$APP_DIR/backups/*.tar.gz ~/backups/invokeai/
	  5. New releases: https://github.com/invoke-ai/InvokeAI/releases

DONE
```

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