Self-host Octop: a multi-user AI assistant
Deploy Octop on a VPS with Docker Compose pinned to a tag: per-user isolation, an OpenAI-compatible model backend, TLS, and why to skip the curl installer.
What Octop is, and why you would self-host it
Octop is a self-hosted AI assistant for a household or a small team, and the reason to self-host Octop rather than a plain chat front end is that it keeps users apart from each other. Open WebUI gives you a browser interface in front of a model. Octop adds accounts with an admin role, a private workspace and credential set for each user, and a library of specialist agents that each user can switch between per task. That is the difference that lets one VPS serve five people instead of one.
The project lives at github.com/TencentCloud/Octop. It is one process that serves a web dashboard, a command line interface, chat channels (Feishu, DingTalk, QQ, Discord, WeCom) and scheduled jobs, all backed by a single SQLite database under ~/.octop/. Everything below is written against tag v0.9.19, released on 5 August 2026. If you are still deciding between platforms, the comparison of Open WebUI alternatives you can run on a VPS covers the wider field.
One thing to be clear about before you spend an evening on it. Octop is pre-1.0 software published from a vendor's GitHub organisation, at around 900 stars as of August 2026. It moves fast, the version numbers say so, and nothing here is a promise of a stable upgrade path. Pin a tag, read the changelog, keep backups.
What you need before you start
- A VPS running Ubuntu 24.04 with Docker Engine and the Compose plugin. New to Compose? Start with the Docker Compose basics for a VPS.
git, because you are going to check out a release tag rather than pull an image.- A domain name pointing at the VPS, because you want TLS (transport layer security) in front of this.
- A model backend that speaks the OpenAI API: a local Ollama, a self-hosted gateway, or a paid key.
Octop itself is light. It is a Python process and a SQLite file. The weight comes from the model backend, so if you plan to run the model on the same box, size the box for the model.
Why we do not recommend the curl installer
The README leads with a one-line install:
curl -fsSL https://finnie-1258344699.cos.ap-guangzhou.myqcloud.com/octop/install.sh | bashWe do not recommend it on a server you care about, for one concrete reason: that script is not in the repository. It is served from a Tencent Cloud Object Storage bucket. Nothing about it is covered by a git tag or a commit, so you cannot diff today's script against last week's, and there is no history that explains a change. The bucket can serve different bytes tomorrow and nothing in the project would record it. Piping the result straight into bash also means the machine runs the script before you have read a line of it.
The installer also writes to the host rather than a container. It uses uv to fetch Python 3.12 and build an environment your package manager knows nothing about, so removing it later is a manual job.
Two better options. Fetch the script, read it, then run it, which costs you thirty seconds: curl -fsSL <url> -o install.sh, then less install.sh, then bash install.sh. Or use Docker, which is the rest of this guide. The PyPI package (pip install octop) is at least a versioned artifact you can pin to a release.
Deploy Octop with Docker Compose, pinned to v0.9.19
There is no published image to pull as of August 2026. The shipped Compose file builds the image from the repository, so pinning a version means checking out a git tag.
git clone https://github.com/TencentCloud/Octop.git
cd Octop
git checkout v0.9.19This is the service the file defines, trimmed to the parts that matter:
services:
octop:
build:
context: ..
dockerfile: docker/Dockerfile
image: octop:latest
container_name: octop
restart: unless-stopped
ports:
- "${OCTOP_PORT:-8088}:${OCTOP_PORT:-8088}"
volumes:
- ${OCTOP_DATA:-~/.octop}:/data/.octop
environment:
- HOME=/data
- OCTOP_BIND_HOST=0.0.0.0
- OCTOP_PORT=${OCTOP_PORT:-8088}
- OCTOP_DEFAULT_PASSWORD=${OCTOP_DEFAULT_PASSWORD:-octop}
- OCTOP_ADMIN_USERNAME=${OCTOP_ADMIN_USERNAME:-admin}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}Note the build: block. image: octop:latest is the name your own build gets, not a registry reference, so latest here means whatever you compiled most recently. Set the data path to somewhere explicit rather than leaving it to a default, and give the admin account a real password before first boot. Put this in docker/.env:
OCTOP_PORT=8088
OCTOP_ADMIN_USERNAME=admin
OCTOP_DEFAULT_PASSWORD=<a long random password>
OCTOP_DATA=/srv/octop-dataOne trap here is worth more than the rest of the file. Compose reads docker/.env only to interpolate ${...} placeholders in the YAML. A key you add to that file does not reach the container unless it is also listed under environment: in the Compose file. Add OCTOP_ACCESS_TOKEN_TTL to .env alone and it does nothing at all, silently. The alternative is to write the same keys into ~/.octop/env inside the mounted data directory, which Octop loads at startup. The guide to env files and secrets in Docker Compose goes through why these two mechanisms are not the same thing.
Build and start it:
docker compose -f docker/docker-compose.yml up -d --build
docker compose -f docker/docker-compose.yml ps
curl http://127.0.0.1:8088/api/healthA healthy instance answers the health check with {"status":"ok","version":"..."}. Anything else, read docker compose -f docker/docker-compose.yml logs -f octop before touching the browser.
Now give the image you just built a name that means something, because the next --build will overwrite octop:latest and you will have no way to tell the two apart:
docker image tag octop:latest octop:0.9.19First boot runs octop init and writes the starting credentials into the data volume:
docker exec -it octop cat /data/.octop/credential.txtThe defaults are admin / octop, and they are applied only on first init. That is the mechanism behind a question people ask constantly: changing OCTOP_DEFAULT_PASSWORD after the container has already started once changes nothing, because the account already exists. Change the password in the dashboard instead.
Do not publish port 8088
The ports: line above binds every interface on the VPS. The moment the container starts, the dashboard is on the public internet in cleartext, with a default password. Octop's own OCTOP_BIND_HOST default is 127.0.0.1; the Compose file overrides it to 0.0.0.0 because the process must accept traffic from outside its own network namespace. That override is correct. The published port is the part that exposes you.
Edit the ports: line in docker/docker-compose.yml so the mapping only listens on loopback:
ports:
- "127.0.0.1:${OCTOP_PORT:-8088}:${OCTOP_PORT:-8088}"Do not try to fix this with a plain override file. Compose concatenates the ports lists from multiple files instead of replacing them, so you end up publishing both mappings and the second one fails to bind. If you want to keep the upstream file untouched, use the !override tag on the sequence, which is the documented way to replace rather than append. The explanation of how Compose merges multiple files covers the rest of those merge rules.
Binding to loopback also solves a problem you would otherwise hit with the firewall. Docker writes its published-port rules into the nat table ahead of the chains ufw manages, so ufw deny 8088 does not stop a published container port. A port bound to 127.0.0.1 is never reachable from outside regardless of what ufw thinks, which is why it is the right fix rather than a second-best one.
Put TLS in front with a reverse proxy
Caddy is the shortest path, because it requests the certificate over ACME (automatic certificate management environment) on its own and proxies WebSockets without being told to:
octop.example.com {
reverse_proxy 127.0.0.1:8088
}nginx needs more care, because Octop streams chat over a WebSocket:
server {
listen 443 ssl;
server_name octop.example.com;
ssl_certificate /etc/letsencrypt/live/octop.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/octop.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8088;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}Every line there is doing a job. Chat runs over WS /agents/{id}/chat/ws, so without proxy_http_version 1.1 and the two upgrade headers, nginx answers the upgrade attempt with 400 Bad Request: the dashboard loads normally and every message you send hangs forever with no error on the page. proxy_buffering off matters because the human-in-the-loop resume endpoint returns text/event-stream, and SSE (server-sent events) held in a proxy buffer arrive as one lump at the end instead of streaming. proxy_read_timeout covers long tool runs, since the 60 second default cuts an agent off mid-task and logs upstream timed out (110: Connection timed out).
How JWT auth behaves behind the proxy
Octop authenticates with a bearer token, not a cookie. POST /api/auth/login returns {access_token, role, user, ...} and later calls carry Authorization: Bearer <access_token>. For a reverse proxy that is good news: there is no cookie domain, no Secure flag and no SameSite rule to get wrong, so a session that worked on http://127.0.0.1:8088 behaves the same on https://octop.example.com.
Two consequences are worth knowing before you put real users on it.
The WebSocket carries the token in the URL. The endpoint is WS /agents/{id}/chat/ws?token=<jwt>, because browser JavaScript cannot set an Authorization header on a WebSocket handshake. TLS protects that token in transit. It does not protect it from your own logs: nginx writes the full request line, query string included, to access_log by default, so a working token for a real user ends up in a plaintext file on the server. Log the path without the arguments. $uri is the normalised path with the query string already stripped, so put this in the http block and reference it from the server:
log_format octop_noargs '$remote_addr [$time_local] '
'"$request_method $uri $server_protocol" '
'$status $body_bytes_sent';
access_log /var/log/nginx/octop.log octop_noargs;There is no per-session logout. OCTOP_ACCESS_TOKEN_TTL defaults to 86400, so a token stays valid for 24 hours after login. The only documented way to invalidate one is octop admin rotate-jwt-secret, which rotates the signing key stored at ~/.octop/secrets/jwt_secret and invalidates every outstanding token immediately, for everybody. So when someone leaves the team, the order is: delete the user, rotate the secret, then tell the remaining users to log in again. If that sounds heavy, shorten the lifetime, remembering to add the variable to the environment: list as well as .env:
OCTOP_ACCESS_TOKEN_TTL=28800Brute force is handled: OCTOP_LOGIN_MAX_ATTEMPTS defaults to 5 failures and OCTOP_LOGIN_LOCKOUT_SECONDS to 900, so a locked-out user is simply waiting fifteen minutes rather than looking at a broken install. Octop has its own user store and no documented OIDC support at v0.9.19, so if you need real single sign-on you put an authenticating proxy in front of it, which is what a self-hosted Authentik server is for.
Point Octop at a model backend
Providers are configured per agent in the dashboard, and octop provider list shows you what is set. Octop ships presets for OpenAI-compatible APIs, DashScope (Qwen) and Ollama, and the credentials are stored in the providers table of your own SQLite database. The choice changes what you pay and what leaves the box.
A local model with Ollama. Nothing leaves the server, and you pay in RAM instead of tokens. The wiring detail that catches people: a container cannot reach the host's Ollama at 127.0.0.1:11434, because that address is the container's own loopback. Add a host gateway entry to the service:
extra_hosts:
- "host.docker.internal:host-gateway"Then set the provider base URL to http://host.docker.internal:11434/v1, which is Ollama's OpenAI-compatible path, and put any non-empty string in the API key field, because Ollama ignores it but OpenAI clients refuse to send an empty one. Ollama must also listen beyond loopback for this to work, which means OLLAMA_HOST=0.0.0.0:11434 in its systemd unit. That is the risky half: Ollama has no authentication, so an open 11434 on a public IP is a free model server for whoever scans it first. Allow only Docker's private range, sudo ufw allow from 172.16.0.0/12 to any port 11434 proto tcp, and deny the rest. Running Ollama on a VPS covers the model sizing, and the Ollama and vLLM comparison covers when Ollama stops being the right server.
One more local-model warning, because it looks like a bug in Octop and is not. Agents work by calling tools, and the system prompt plus tool definitions plus history is a large prompt. Ollama serves models with a modest default context window, so the front of the prompt, which is where the tool definitions live, falls out of the window. The model then stops calling tools or invents ones that do not exist. Raise num_ctx to 16k or 32k and pick a model that is actually good at function calling.
A self-hosted gateway. Put a self-hosted LiteLLM gateway between Octop and everything else and you get one base URL, a separate key per user, spend limits and a single log. You can also swap the model behind it without editing anything in Octop.
A paid API. The best quality, with an honest tradeoff: conversation content leaves your server and reaches the provider, which is most of what self-hosting was for. The key goes in docker/.env as OPENAI_API_KEY, which the Compose file already passes through.
Whichever you pick, the Compose file also carries OCTOP_LANGFUSE_ENABLED, LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY and LANGFUSE_BASE_URL, so you can send traces to your own Langfuse instance and see what the agents are actually doing rather than guessing from the chat window.
Users, roles, and the shared agent library
The admin account from first boot creates and manages the others. Each user gets their own agents, workspace and credentials, and that isolation is carried by the token the browser holds. Alongside it sits a shared pool of skills and sub-agents that anyone can use, which is the feature that makes this worth running for a family: one person builds a good research agent once, and nobody else has to rebuild it.
Be careful with the tooling. Octop advertises tool approval and shell command guardrails, and both are real, but an agent that runs shell commands runs them inside the Octop container with your data volume mounted. Guardrails reduce what a careless prompt can do. They are not a sandbox boundary, so leave tool approval on for anyone you would not hand a shell to. If you are weighing this against other options, the roundup of self-hosted AI agents compares how each one handles that.
Upgrading a project that ships this fast
The data behind this chart
[
{
"version": "v0.9.16",
"days_since_previous_release": 2
},
{
"version": "v0.9.17",
"days_since_previous_release": 3
},
{
"version": "v0.9.18",
"days_since_previous_release": 1
},
{
"version": "v0.9.19",
"days_since_previous_release": 3
}
]Those are the tag dates from the repository, counted as of 7 August 2026. 4 tagged releases landed in nine days, with a gap as short as 1 day, and v0.9.19 arriving 3 days after the tag before it. That cadence is a good sign about the project and a bad reason to run latest. Read the changes before you take them:
cd Octop
git fetch --tags
git tag --sort=-creatordate | head
NEW_TAG=$(git tag --sort=-creatordate | head -1)
git log --oneline "v0.9.19..$NEW_TAG"Back up first, every time, because database migrations run at startup and a failed migration on a pre-1.0 project is your problem to unpick:
docker compose -f docker/docker-compose.yml stop
sudo tar czf octop-backup-$(date +%F).tgz -C /srv octop-data
docker compose -f docker/docker-compose.yml startThen check out the new tag and rebuild with docker compose -f docker/docker-compose.yml up -d --build. If it goes wrong, checking out the old tag and rebuilding gets the code back, but only the tarball gets the database back.
That tarball holds octop.db, config.json, the JWT signing secret and credential.txt, so it is as sensitive as the server itself. Keep it at mode 600 and keep a copy off the box. For a larger install the project also ships docker/docker-compose.postgres.yml, which runs PostgreSQL with pgvector instead of SQLite.
Failure modes, with the strings you will see
The health check never answers. curl http://127.0.0.1:8088/api/health hangs or refuses. Read docker compose -f docker/docker-compose.yml logs -f octop. A container that exits during first init usually cannot write the data directory, so check the ownership of whatever you set OCTOP_DATA to.
The dashboard loads but chat hangs. No error on the page, no reply ever. Open the browser console and look for a failed connection to wss://octop.example.com/agents/.../chat/ws. The proxy is not forwarding the upgrade. Add proxy_http_version 1.1 and the Upgrade and Connection headers.
The whole reply appears at once, several seconds late. Streaming works, buffering is on. Set proxy_buffering off.
bind: address already in use. Something already holds 8088. sudo ss -tlnp | grep 8088 names it. This is also what you get if you added a second ports entry in an override file instead of editing the original.
The correct password is rejected. Five bad attempts trigger a 900 second lockout. Wait it out rather than reinstalling.
The new password in .env had no effect. Those credentials apply on first init only. Change it in the dashboard.
The agent replies but never runs a tool. Almost always a local model problem: the context window is too small for the tool definitions, or the model is weak at function calling. Raise num_ctx and try a model built for tool use.
FAQ
Is Octop a replacement for Open WebUI?
Only if you need what it adds. Open WebUI is a chat interface in front of a model and it does that job well for one person or a trusting household. Octop adds accounts with an admin role, per-user workspaces and credentials, and a switchable library of specialist agents, so several people can share one server without sharing one history. If a single account is fine for you, Open WebUI is the simpler and much more mature choice.
Why should I not use the Octop curl install script?
The script is served from a Tencent Cloud Object Storage bucket rather than from the repository, so it is not covered by any git tag or commit. You cannot compare what it does today with what it did last week, and piping it into bash runs it before you read it. It also installs onto the host with its own Python 3.12 environment, outside your package manager. Download it and read it first, or deploy with Docker Compose from a checked-out tag.
Can Octop use a local model instead of a paid API?
Yes. Octop speaks OpenAI-compatible APIs and ships an Ollama preset, so pointing it at http://host.docker.internal:11434/v1 works once you add extra_hosts: ["host.docker.internal:host-gateway"] to the container and set OLLAMA_HOST=0.0.0.0:11434 on the host. Firewall port 11434 to Docker's address range, because Ollama has no authentication of its own. Expect to raise Ollama's num_ctx to 16k or higher, since agent prompts with tool definitions overflow the default context window and the model then stops calling tools.
Do I need a reverse proxy, or can I open port 8088?
You need the proxy. Octop's shipped Compose file publishes 8088 on every interface with no TLS, so passwords and bearer tokens would cross the internet in cleartext. Change the published port to 127.0.0.1:8088:8088 and put Caddy or nginx in front with a certificate. With nginx, forward the WebSocket upgrade headers and set proxy_buffering off, or the page will load while chat silently never responds.
Is Octop ready for production?
It is pre-1.0 and shipping several tagged releases per week as of August 2026, so treat it as promising rather than settled. That is workable for a family or a small internal team if you pin an exact tag, read the commit log before each upgrade, and take a backup of the data volume before every rebuild. Do not run it on latest, and do not put customer data in it yet.