Zalgorithm

Deploying a Docker applications to a VPS

Install Docker on the VPS

My first thought was sudo apt install docker. It turns out that’s wrong. The official Docker installation instructions for Ubuntu are here: Install Docker Engine on Ubuntu. I went with the “Set up and install Docker Engine from Docker’s apt repository” approach. The documentation is good.

The Docker service starts automatically after installation. Its status can be verified with:

sudo systemctl status docker

If a manual start is required, it can be done with:

sudo sytemctl start docker

On Ubuntu, Docker automatically starts on boot by default.

After the installation step I followed the post-install instructions to add my user to the docker group. This allows Docker commands to be run without using sudo.

Docker daemon configuration

I also turned on log-rotation by creating a daemon.json configuration file at /etc/docker/. Documentation is here: daemon configuration file documentation.

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}

The Docker daemon (dockerd) is the persistent process that manages containers. Note that I’m (fairly sure) I didn’t explicitly start the Docker daemon during the setup process.

After creating the daemon.json file, I restarted Docker with sudo systemctl restart docker, then ran sudo systemctl status docker to confirm things were good.

Note that existing containers don’t use new settings that have been added to the daemon.json file. To apply the settings to existing containers, recreate them with:

docker compose down && docker compose up -d

Viewing docker logs

Getting ahead of myself, Docker logs are stored per container at /var/lib/docker/containers/<container-id>/<container-id>-json.log, but you don’t normally view them directly (it seems to require root privileges to cd into that directory). Normally logs are viewed with:

docker logs <container-name>
docker logs --tail 100 <container-name>
docker logs -f <container-name>

With <container-name> being the name of a container that is found with the docker ps command.

Getting the Docker app into the VPS

I just used git to clone it into my home directory:

cd ~
git clone https://github.com/scossar/zalgorithm_search
cd zalgorithm_search

The application

This was quick and dirty. The application is a single API route created with FastAPI. POST requests to the /query route query a Chroma client that stores embeddings I’ve generated for my blog. The embeddings are generated on my local computer.

The (current (January 1, 2026)) code:

from fastapi import FastAPI, Form, HTTPException
from fastapi.responses import HTMLResponse
from typing import Annotated
from fastapi.middleware.cors import CORSMiddleware
import chromadb
import os

app = FastAPI()

collection_name = "zalgorithm"

chroma_host = os.getenv("CHROMA_HOST", "localhost")
chroma_port = os.getenv("CHROMA_PORT", "8000")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:1313", "https://zalgorithm.com"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.post("/query", response_class=HTMLResponse)
async def query_collection(query: Annotated[str, Form()]):
    try:
        chroma_client = await chromadb.AsyncHttpClient(
            host=chroma_host, port=int(chroma_port)
        )
        collection = await chroma_client.get_collection(name=collection_name)
        results = await collection.query(query_texts=[query], n_results=5)

        seen_sections = set()
        html_parts = []

        if not results["metadatas"]:
            return ""  # do better

        for i in range(len(results["ids"][0])):
            metadata = results["metadatas"][0][i]
            section_heading = metadata.get("section_heading", "")
            if section_heading in seen_sections:
                continue

            seen_sections.add(section_heading)

            heading = metadata.get("html_heading", "")
            fragment = metadata.get("html_fragment", "")
            html_parts.append(str(heading) + str(fragment))

        response_html = "".join(html_parts)
        return response_html

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

The tricky part was figuring out how to get locally generated embeddings into the container. (See Generating embeddings for blog semantic search for details about how the embeddings are created.)

The trick is to use Docker Volumes. “Volumes are persistent data stores for containers, created and managed by Docker.” Volumes can be created through a docker-compose.yml file: Use a volume with Docker Compose.

For local development, I’d set things up this way (docker-compose.yml):

services:
  chroma:
    image: chromadb/chroma:latest
    ports:
      - "8001:8000"
    volumes:
      - /home/scossar/projects/python/semantic_search/chroma:/data
    environment:
      - IS_PERSISTENT=TRUE

  api:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      - chroma
    volumes:
      - ./app:/code/app
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload # reload on changes to /app
    environment:
      - CHROMA_HOST=chroma
      - CHROMA_PORT=8000

That worked great. The volume for the chroma service meant that the /semantic_search/chroma directory could be accessed by the Docker container at /data. The ./app volume was used to automatically reload the app when I made changes to its code.

The problem for the production site is that the recommended practice is to use a named volume instead of the approach I used locally (which I think is technically called a bind mount).

Production docker-compose.yml:

services:
  chroma:
    image: chromadb/chroma:latest
    ports:
      - "127.0.0.1:8001:8000"
    volumes:
      - chroma-data:/data # named volume
    environment:
      - IS_PERSISTENT=TRUE
    restart: unless-stopped

  api:
    build: .
    ports:
      - "127.0.0.1:8000:8000"
    depends_on:
      - chroma
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000
    environment:
      - CHROMA_HOST=chroma
      - CHROMA_PORT=8000
    restart: unless-stopped

volumes:
  chroma-data:

To be able to use (sort of) the same configuration in production and development, I created a docker-compose.override.yml file that’s not committed to the Git repo. Docker automatically pulls in this file to override settings from the main docker-compose.yml file when docker compose up is run locally.

services:
  chroma:
    volumes:
      - /home/scossar/projects/python/semantic_search/chroma:/data
    restart: "no"

  api:
    volumes:
      - ./app:/code/app
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
    restart: "no"

Getting Chroma data onto the production server

First, use Rsync to get the data from local into the VPS /tmp directory:

rsync -avz --delete /home/scossar/projects/python/semantic_search/chroma/ me@foo.com:/tmp/chroma-data/

Then, start the container to create the named volume, copy the data into the volume, and restart the chroma application:

docker compose up -d
docker cp /tmp/chroma-data/. zalgorithm_search-chroma-1:/data/
docker compose restart chroma
Tags: