# How to Self-Host Specivo on a 2GB Ubuntu Server

Specivo is self-hosted, so the whole thing runs on a server you control: the app, the database, the search index, the background workers. This guide walks through standing up a fresh instance on a small cloud VM, putting it behind HTTPS on your own domain, turning on semantic search, and creating your first project, wiki page, and issue. By the end you'll have a working instance and you'll have seen search find a page using words that never appear in it.

Everything here was run on a brand-new $12/month DigitalOcean droplet (1 vCPU, 2 GB RAM, Ubuntu 24.04). Any provider works — Hetzner, Vultr, a VM on your own hardware — as long as you get a Linux host with root and a public IP.

## What you'll end up with

- Specivo running in Docker (app, PostgreSQL, Redis, nginx, and two Celery workers)
- A real Let's Encrypt certificate on your own domain
- Semantic ("by meaning") search switched on
- A demo project with a wiki page and a support issue, and proof that search finds them

The examples use the domain `specivo.acmewasterecycling.com`. Substitute your own domain wherever you see it.

## Before you start

You need three things:

1. **A server.** 2 GB of RAM is the realistic minimum. Specivo runs comfortably at the low end; the embedding model for semantic search is the heaviest single piece, and even that fits in 2 GB for a small team.
2. **A domain you control**, so you can point a subdomain at the server and issue a TLS certificate.
3. **An SSH key.** Cloud providers let you attach your public key when you create the server, which is far safer than password login. The rest of this guide assumes you log in as `root` over SSH with key authentication.

## Step 1 — Create the server

In DigitalOcean (or your provider of choice), create a droplet:

- **Image:** Ubuntu 24.04 LTS
- **Plan:** Basic, 1 vCPU / 2 GB RAM / 50 GB disk
- **Authentication:** your SSH key

Note the public IP it gives you. From here on, everything happens over SSH:

```bash
ssh root@YOUR_SERVER_IP
```

## Step 2 — Point your domain at the server

Add a DNS **A record** for your subdomain pointing at the droplet's IP:

```
specivo.acmewasterecycling.com.   A   YOUR_SERVER_IP
```

Do this early. DNS takes a few minutes to propagate, and you'll need it resolving before you can get a certificate in Step 7. Check it with:

```bash
dig +short specivo.acmewasterecycling.com
```

When that prints your droplet's IP, you're ready.

## Step 3 — Install Docker

Specivo ships as Docker containers, so the only thing the host needs is Docker and the Compose plugin. Install them from Docker's official repository:

```bash
apt-get update
apt-get install -y ca-certificates curl git

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
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/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
  > /etc/apt/sources.list.d/docker.list

apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
```

Confirm it works:

```bash
docker --version
docker compose version
```

## Step 4 — Get Specivo and configure it

Clone the repository. You only need it for the Compose file and nginx config; the application image itself is pulled from Docker Hub.

```bash
cd /opt
git clone https://github.com/specivo/specivo.git
cd specivo
```

Specivo is configured through two files: `.env` for non-secret defaults and `.env.local` for secrets. There's an interactive wizard (`make configure`), but it's just as easy to write the files directly, which is what you'd do in any automated setup.

First, generate two strong secrets — one for the application's `SECRET_KEY`, one for the database password:

```bash
python3 -c "import secrets; print(secrets.token_hex(64))"   # SECRET_KEY
python3 -c "import secrets; print(secrets.token_hex(16))"   # database password
```

Create `.env` (replace the database password and the domain with your own):

```bash
COMPOSE_PROJECT_NAME=specivo
SPECIVO_PORT=9933
SPECIVO_DATA_DIR=./specivo-data
SPECIVO_VERSION=latest

DATABASE_URL=postgresql+asyncpg://specivo:YOUR_DB_PASSWORD@db:5432/specivo
POSTGRES_DB=specivo
POSTGRES_USER=specivo
POSTGRES_PASSWORD=YOUR_DB_PASSWORD

REDIS_URL=redis://redis:6379/0

DEBUG=false
REGISTRATION_MODE=invite_only
CAPTCHA_ENABLED=false
CORS_ORIGINS=["https://specivo.acmewasterecycling.com"]
ALLOWED_HOSTS=["specivo.acmewasterecycling.com","localhost","127.0.0.1"]
```

Create `.env.local` with your generated key, and lock its permissions:

```bash
SECRET_KEY=YOUR_GENERATED_SECRET_KEY
```

```bash
chmod 600 .env.local
```

A few of those settings are worth understanding:

- `REGISTRATION_MODE=invite_only` means strangers can't sign themselves up on a public URL. Other options are `open` and `disabled`.
- `ALLOWED_HOSTS` and `CORS_ORIGINS` pin the instance to your domain. With `DEBUG=false`, requests arriving with the wrong `Host` header are rejected outright.
- `SPECIVO_VERSION=latest` is fine to start with. For a deployment you depend on, pin a specific release (for example `0.1.9`) so upgrades are something you choose rather than something that happens to you.

Finally, create the data directories that the containers mount for persistent storage:

```bash
mkdir -p specivo-data/{postgresql,redis,attachments,themes,models,logs/nginx,logs/app}
```

## Step 5 — Start the stack

```bash
docker compose up -d
```

The first run downloads the images and can take a minute. The API container's entrypoint waits for the database, applies all migrations, and seeds the default trackers, statuses, and priorities — you don't run any of that by hand. Watch it come up:

```bash
docker compose ps
```

When `api` and `db` report `healthy`, you have six containers running: `nginx`, `api`, `db` (PostgreSQL with pgvector), `redis`, `celery-worker`, and `celery-beat`. At this point the app answers on port 9933 inside the server, but nothing is exposed to the world yet — that's the next two steps.

## Step 6 — Create your first admin

There's no public sign-up for the very first account; you create it from the command line.

> **Don't call it `admin`.** Automated login attacks start with the obvious usernames — `admin`, `administrator`, `root` — and pair them with password dictionaries. Choosing a non-obvious username means an attacker has to guess two unknowns instead of one, and almost every drive-by brute-force attempt fails on the username alone. Use something specific to you, like `alex.ops` or `mwiggins`.

Generate a strong password and create the account:

```bash
docker compose exec api python -m specivo.cli.admin create \
  --login alex.ops --email you@example.com --password 'a-long-random-password'
```

This account can create projects, invite the rest of your team, and manage everything from the web UI.

## Step 7 — Put it behind HTTPS

Right now the app speaks plain HTTP on port 9933. The clean way to expose it is to run nginx on the host as a reverse proxy: it terminates TLS on ports 80/443 and forwards to Specivo's container. Install nginx and Certbot:

```bash
apt-get install -y nginx certbot python3-certbot-nginx
```

Create the site config at `/etc/nginx/sites-available/specivo.acmewasterecycling.com`:

```nginx
server {
    listen 80;
    listen [::]:80;
    server_name specivo.acmewasterecycling.com;

    # Allow large attachment uploads
    client_max_body_size 50m;

    location / {
        proxy_pass http://127.0.0.1:9933;
        proxy_http_version 1.1;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header Upgrade           $http_upgrade;
        proxy_set_header Connection        "upgrade";
        proxy_read_timeout 120s;
    }
}
```

Enable it, drop the default site, and reload:

```bash
ln -sf /etc/nginx/sites-available/specivo.acmewasterecycling.com /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
```

Now get a certificate. Certbot edits the nginx config for you, adds the HTTPS server block, and sets up a redirect from HTTP:

```bash
certbot --nginx -d specivo.acmewasterecycling.com \
  --non-interactive --agree-tos -m you@example.com --redirect
```

Open `https://specivo.acmewasterecycling.com` and you should see the sign-in page over a valid certificate. Certbot also installs a renewal timer, so the certificate refreshes itself before it expires.

![Specivo sign-in page over HTTPS](https://specivo.io/media/tutorial/01-login.png)

Sign in with the admin account you created. The first thing you'll see is an empty dashboard:

![Empty Specivo dashboard](https://specivo.io/media/tutorial/02-dashboard-empty.png)

## Step 8 — Close the back door

There's one loose end. Docker publishes container ports by writing iptables rules directly, which sail straight past `ufw`. So even with a firewall, port 9933 may still be reachable from the internet — letting people hit the app over plain HTTP and bypass your certificate.

Fix it by binding the published port to localhost only. Edit the `nginx` service's port mapping in `docker-compose.yml`:

```yaml
    ports:
      - "127.0.0.1:${SPECIVO_PORT:-9933}:80"
```

Recreate the container and turn on a basic firewall (allow SSH first, so you don't lock yourself out):

```bash
docker compose up -d nginx

ufw allow OpenSSH
ufw allow "Nginx Full"
ufw --force enable
```

Now 9933 answers only to the host nginx over the loopback interface; the public can reach Specivo only through HTTPS on 443.

## Step 9 — Turn on semantic search

Out of the box, search matches on words (PostgreSQL full-text search). Semantic search adds matching *by meaning* — a query like "garbage truck collection days" can find a page about "emptying residential bins on a weekly rotation," even with no words in common. It runs on an on-device model with no external API calls.

Download the model (multilingual-e5-small, MIT-licensed, ~400 MB):

```bash
cd /opt/specivo
bash scripts/download-model.sh
```

The files land in `specivo-data/models/`. Restart the app so it picks them up, then embed anything that already exists:

```bash
docker compose restart api celery-worker celery-beat
docker compose exec api python -m specivo.cli.backfill_embeddings
```

You'll see it report the model loading and the number of issues and wiki pages it indexed. From now on, new content is embedded automatically in the background as you create it. (On a 1-vCPU box the model takes a few seconds per batch — fine for a small team. If your instance is very write-heavy, give it a second core.)

## Step 10 — Create a project

The rest is in the browser. From **Projects → New Project**, give it a name, a short identifier, and an uppercase key (the key prefixes every issue, like `ACME-1`). Issues are always on; Wiki and Time tracking are optional modules.

![New project dialog](https://specivo.io/media/tutorial/03-new-project.png)

After you create it, the project shows up with its modules:

![Project created](https://specivo.io/media/tutorial/04-project-created.png)

## Step 11 — Write a wiki page

Open the project's **Wiki** and choose **New Page**. The editor is Markdown, with a live preview and a slug generated from the title.

![Writing a wiki page in Markdown](https://specivo.io/media/tutorial/05-wiki-new.png)

Save, and Specivo renders it with a table of contents built from your headings. Every save is versioned, so you can see the history and roll back.

![Rendered wiki page](https://specivo.io/media/tutorial/06-wiki-page.png)

## Step 12 — Create an issue

From the project's **Issues**, click **New Issue**. Pick a tracker (Bug, Feature, Task, Support), write a subject and a Markdown description, and set a priority.

![New issue form](https://specivo.io/media/tutorial/07-issue-new.png)

It lands in the issue list with its generated key:

![Issue list](https://specivo.io/media/tutorial/08-issues-list.png)

The issue detail page is where the work happens — status, comments, relations to other issues, attachments, and time entries:

![Issue detail](https://specivo.io/media/tutorial/09-issue-detail.png)

## Step 13 — Prove that search works

This is the payoff. Open **Search** and run a query that describes the wiki page *without using any of its words*. Try **"garbage truck collection days"**. The page says nothing about "garbage," "trucks," or "days"; it talks about "emptying residential bins on a weekly rotation." With **Hybrid** mode on, it comes back as the top result anyway, with the issue close behind:

![Semantic search results](https://specivo.io/media/tutorial/10-search-semantic.png)

Switch the same query to **Keyword** mode and you get nothing — because none of those exact words appear in the content:

![Keyword search returns nothing](https://specivo.io/media/tutorial/11-search-keyword-zero.png)

That contrast is the whole point of semantic search: people find what they're looking for even when they don't remember the exact wording your team used.

Back on the dashboard, your project, issue, and recent wiki activity are now all visible at a glance:

![Populated dashboard](https://specivo.io/media/tutorial/12-dashboard-populated.png)

## A production checklist

Before you put real work into the instance:

- **Set a unique `SECRET_KEY`** in `.env.local` (you did this in Step 4 — just don't ship the bundled default).
- **Pin `SPECIVO_VERSION`** to a specific release instead of `latest`.
- **Keep `REGISTRATION_MODE` closed** (`invite_only` or `disabled`) on any public URL.
- **Back up two things together:** a PostgreSQL dump and the `specivo-data/` directory (attachments, logs, the model). Restoring one without the other leaves you half a system.
- **Watch memory** on a 2 GB box. The stack plus the embedding model is comfortable for a small team, but if you run other services on the same host, give it more headroom or a swap file.

## What's running, and what it costs

A default install is six containers: nginx (the front door), the API, PostgreSQL with pgvector (this is also your search index, so there's no Elasticsearch to operate), Redis (cache and task broker), and two Celery processes for background jobs like embedding and notifications. On the 2 GB droplet used here, the whole stack with semantic search enabled sits comfortably within RAM with room to spare. You don't stand up a managed database, an external search service, or a message queue separately. It's the one host and the containers on it.

That's the entire setup: a server, a domain, Docker, and a certificate, and you own every byte of it.

## Keeping it up to date

New Specivo releases ship as new `specivo/specivo` images. Upgrading is deliberate and quick — back up, pin the new version, pull, and restart, and the container applies any new migrations on startup. When you're ready to move to a newer release, follow [How to upgrade a self-hosted Specivo instance](/upgrade-self-hosted-specivo/).