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