Run AI Tutor yourself

On your own server, with student data inside your own borders.

Where data goes. Course material, uploaded figures and student records stay in your database and on your disk. Embeddings are computed locally — the all-MiniLM-L6-v2 model runs inside the app container, so your curriculum is never sent anywhere to be indexed. But the text of each student message is sent to Anthropic, OpenAI or Google to be answered, leaving your network. If that is not acceptable, run a local model instead — see below.

First start needs internet even with a local model: the app downloads the embedding model (~90 MB) from HuggingFace on first boot. For an air-gapped install, warm the cache on a connected machine first and copy the volume across.

Pick your deployment shape before you start

Almost every install problem below comes from picking the wrong one, because the default configuration assumes the first.

Public server LAN-only Laptop
Reachable from internet school network one machine
Needs a domain yes no no
HTTPS automatic none none
SITE_DOMAIN your real domain localhost localhost
Caddy site block {$SITE_DOMAIN} :80 :80
HTTPS_EDGE true false false
Port binding 80:80 80:80 127.0.0.1:80:80
The LAN-only and laptop shapes send session and CSRF cookies in the clear. Only use them where the traffic never leaves a building you control, or on a single machine. Never on café or conference wifi with real student data.

With Dockerrecommended

A Linux server with 4 vCPU, 8 GB RAM and 60 GB disk; Docker with the Compose plugin; a domain pointing at it, for the public-server shape only. Brings its own database and certificates.

1Get the deployment files

mkdir -p ai-tutor && cd ai-tutor

# the deployment files ship inside the image, so they always match it
docker run --rm ghcr.io/eai6/ai-tutor:latest \
  tar -C /app/deploy/compose -c \
      docker-compose.yml Caddyfile env.example backup.sh restore.sh > deploy.tar

tar -tvf deploy.tar   # inspect: no leading / and no .. in any path
tar -xf deploy.tar
Where the files land, and what to read before running them

Files land flat in ai-tutor/, not in a deploy/compose/ subdirectory. This is also where backup.sh lives, so that is the directory you run it from later.

That first docker run already pulls the full image (~7 GB), so the docker compose pull in step 4 is usually instant.

Read docker-compose.yml and Caddyfile before running them. Both are commented and short. Caddyfile in particular contains a ready-made plain-HTTP block you will need if you are not on the public-server shape.

Optionally pin the exact image, so a later pull cannot drift from the compose file you just extracted:

docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/eai6/ai-tutor:latest
# put the resulting name@sha256:... into the app service's image: line

2Configure .env

cp env.example .env && chmod 600 .env
env.example ships placeholder values, not blanksSITE_DOMAIN=tutor.education.gov.xx and ACME_EMAIL=ict@education.gov.xx. These look filled in and are not. Replace both, or Caddy will spend thirty days failing to get a certificate for a domain that does not exist.
Every required variable, and what to put in it
VariableNotes
SECRET_KEY python3 -c 'import secrets; print(secrets.token_urlsafe(64))'
POSTGRES_PASSWORD avoid @ : / ? — they break DATABASE_URL parsing
SITE_DOMAIN compose enforces this with :?. Use localhost if you have no domain
ALLOWED_HOSTS the hostname students will type, comma-separated if more than one
CSRF_TRUSTED_ORIGINS must include the scheme
one provider API key or use the local-llm profile

Optional: ACME_EMAIL (only used when requesting a certificate) and HTTPS_EDGE.

Public server:

SITE_DOMAIN=tutor.yourdomain.example
ACME_EMAIL=you@yourdomain.example
ALLOWED_HOSTS=tutor.yourdomain.example
CSRF_TRUSTED_ORIGINS=https://tutor.yourdomain.example
HTTPS_EDGE=true

LAN-only or laptop:

SITE_DOMAIN=localhost
#ACME_EMAIL=
ALLOWED_HOSTS=localhost,127.0.0.1
CSRF_TRUSTED_ORIGINS=http://localhost
HTTPS_EDGE=false

Duplicate keys are easy to introduce while editing, and the last one silently wins. Check:

grep -n 'HTTPS_EDGE\|ALLOWED_HOSTS\|CSRF_TRUSTED\|SITE_DOMAIN' .env

3Configure Caddy — only if you are not on a public domain

On the public-server shape there is nothing to do here. Skip to step 4.

Switching Caddy to plain HTTP (LAN-only or laptop)

Keep a copy of the original first: cp Caddyfile Caddyfile.orig. Then delete the global email block and replace the whole {$SITE_DOMAIN} block with the plain-HTTP block already commented at the bottom of the file:

:80 {
    encode gzip

    reverse_proxy app:8000 {
        header_up X-Real-IP {remote_host}
        transport http {
            read_timeout 300s
            write_timeout 300s
        }
    }

    log {
        output stdout
        format console
    }
}
Both changes are needed — this one and HTTPS_EDGE=false. Doing only one gives you a silent 403; see troubleshooting.

Laptop only: in docker-compose.yml, bind Caddy to loopback and drop 443, which nothing listens on now.

    ports:
      - "127.0.0.1:80:80"

"80:80" binds every interface including wifi. Docker writes its own iptables rules ahead of ufw, so a firewall rule will not substitute for this — the bind address is the only control that works.

4Start it

docker compose pull        # ~7 GB, once
docker compose up -d
docker compose logs -f app
Wait before doing anything else. up -d returns when containers have started, not when the app is usable. First boot runs 200+ database migrations, downloads the embedding model and indexes the seeded curriculum — a couple of minutes on modest hardware.
What “ready” looks like in the log

A line like this one:

Indexed. Collection size: 0 → 151.

Or check the health state, which is the same signal:

docker compose ps    # wait for app to show (healthy)

A BertModel LOAD REPORT line flagging embeddings.position_ids as UNEXPECTED during this is normal and can be ignored.

5Create the admin account

Only after the app reports healthy.

docker compose exec app python manage.py createsuperuser
Type a username — do not accept the default

Leaving it blank accepts the container's OS user, which gives you an account called root — easy to do by accident and confusing later. To fix it afterwards:

docker compose exec app python manage.py changepassword <name>

No API key? docker compose --profile local-llm up -d runs a small model on the server instead. Weaker at tutoring than a cloud model, and slower without a GPU — but it needs no account anywhere and nothing leaves your network. If you already run an OpenAI-compatible endpoint of your own, point a model config at it instead.

With pip, no Docker

For a server where containers are not allowed. Needs Python 3.12 or newer and PostgreSQL with the pgvector extension. You supply the database, the reverse proxy and the file ownership yourself: this path automates much less than the Docker one, and every step below that looks fussy is there because skipping it produces a failure that does not name its own cause.

No wheel has been published yet. docs/self-hosting.md in the repository has the full procedure in the meantime.

Then, whichever you chose

Sign in at /admin/. Under LLM → Model configs, add a provider config and mark it active — a config that exists but is not selected fails silently later.

Then take one tutoring turn against the seeded curriculum, with docker compose logs -f app open beside you. That single action exercises the database, the vector store, the credentials and media serving together — a health check does not.

Allow 20–90 seconds for the reply. If it fails, the log names the layer: an auth error is the key, a timeout is egress from the container, and a reply with no retrieved context means the model works but retrieval is not wired to the active config.

Troubleshooting

Login form reloads, no error message

The commonest failure, and a silent one. Django marks session and CSRF cookies Secure; a browser will not send those over plain HTTP, so the login simply bounces.

Finish the HTTPS setup, or set HTTPS_EDGE=false and switch the Caddyfile to the :80 block. Both are required. Then run docker compose up -d — not restart, see below.

If it persists, check what Django actually resolved:

docker compose exec app python -c "
import django; django.setup()
from django.conf import settings
print(settings.ALLOWED_HOSTS, settings.CSRF_TRUSTED_ORIGINS)
"
.env edits appear to do nothing

docker compose restart does not re-read .env. Neither do port or volume changes take effect. Always use docker compose up -d, which recreates the affected containers.

relation "auth_user" does not exist

You ran createsuperuser before migrations finished — most likely by pasting it together with up -d. Nothing is broken. Wait for the app to report healthy and run it again.

ERR_SSL_PROTOCOL_ERROR / “sent an invalid response”

Caddy is redirecting to HTTPS but has no certificate to serve for the hostname you asked for. Either the certificate was never issued (see the next entry), or you are browsing to localhost while the only site block is for {$SITE_DOMAIN} — no site matches, so the handshake is aborted before any HTTP happens. Switch to the :80 block.

invalidContact — domain does not end with a valid public suffix

ACME_EMAIL is still the placeholder from env.example. .xx is not a real TLD, so Let's Encrypt refuses the account and Caddy falls back to ZeroSSL, which refuses too. Caddy then retries with backoff for 30 days.

Set a real address, or remove the ACME block entirely if you are not using a public domain. The same applies to SITE_DOMAIN: issuance also needs the domain to already resolve to this server, with ports 80 and 443 reachable from the internet. A laptop behind NAT cannot satisfy that.

To clear the failed account state afterwards:

docker compose down && docker volume rm ai-tutor_caddy_data
Browser still redirects to HTTPS after fixing the Caddyfile

The old redirect was a 308 Permanent, and browsers cache those aggressively — you will be sent to https:// without Caddy ever being asked. Use a private window, or clear site data for that host.

Confirm from the shell, which does not cache:

curl -sI http://localhost/admin/    # want 302 to /admin/login/, not 308
Docker on Kali: apt has no repository for it

Docker's apt repository has no kali-rolling suite. Use Kali's own package, then install the Compose plugin binary directly rather than falling back to the v1 docker-compose script:

sudo apt install docker.io
mkdir -p ~/.docker/cli-plugins
curl -SL https://github.com/docker/compose/releases/latest/download/docker-compose-linux-x86_64 \
  -o ~/.docker/cli-plugins/docker-compose
chmod +x ~/.docker/cli-plugins/docker-compose

Adding yourself to the docker group is effectively passwordless root — anyone who can reach the Docker socket can mount / into a container. On a workstation, consider sudo docker instead.

pip path: the service crash-loops with PermissionError on ai-tutor.env

ai-tutor init runs as root and writes the file 0600 root:root. ai-tutor systemd generates a unit that runs as an unprivileged user. Nothing reconciles them.

See pip step 5 — chown root:<user> and chmod 640 the env file, chown -R the data directory, then systemctl reset-failed before restarting.

pip path: every page returns 500, “Missing staticfiles manifest entry”

collectstatic was never run. Django's manifest storage raises rather than degrading to unstyled output, so even the login form fails. See pip step 6.

Note that curl -I /admin/ still returns a healthy 302, because a redirect renders no template — this failure is only visible in a browser.

pip path: it started, but is it using the right database?

If DATABASE_URL is unset, Django falls back to SQLite and everything appears to work — with no pgvector behind retrieval. Run ls -la /var/lib/ai-tutor/: a db.sqlite3 there means the fallback happened.

Keep it running

Back up both halves together. The database holds the reference to every uploaded figure and the disk holds the file, so a database restored against older media leaves lessons with broken images and reports no error.

# Docker — from the directory you extracted into
cd ai-tutor && ./backup.sh

# pip
pg_dump -Fc aitutor > /mnt/backup/aitutor-$(date +%F).dump
tar czf /mnt/backup/media-$(date +%F).tar.gz -C /var/lib/ai-tutor media

A backup that only exists on the machine it backs up is not a backup, and these contain student records — store them encrypted, off the machine.

Test a restore before you need it, while the only data is seed data and your own test conversations. That is the one moment when getting it wrong costs nothing.

Worth also confirming state survives a stop, which tells you the named volumes are doing their job — sign in again afterwards, and the curriculum should still be indexed:

docker compose down && docker compose up -d

Upgrading

# Docker — migrations run on start
docker compose pull && docker compose up -d

# pip — nothing is automatic here
sudo /opt/ai-tutor/venv/bin/pip install --upgrade ai-tutor
sudo /opt/ai-tutor/venv/bin/ai-tutor migrate
sudo systemctl restart ai-tutor

Take a backup first — migrations are not reversible. And if you edited Caddyfile or docker-compose.yml, keep those edits under version control or in .orig copies: a future release may ship changed versions, and you will want to see what changed rather than re-deriving your local modifications from memory.