Skip to content

Deploy on a VPS (plain Docker)

The lowest-ceremony deployment there is: one box, Docker, and a TLS terminator in front. No platform, no control plane, no per-seat pricing — which is the whole reason skein ships an ordinary image.

Everything platform-agnostic — environment variables, pool sizing, probes, scaling caveats — is in deploy.md.

The quickest version

If you already have a Postgres and a Redis:

bash
skein build -t my-agent
docker run -d --name my-agent -p 8123:8123 \
  -e POSTGRES_URI="postgresql://user:password@host:5432/skein" \
  -e REDIS_URI="redis://host:6379" \
  --restart unless-stopped --init \
  my-agent
curl -s localhost:8123/ok      # {"ok":true}

No PORT needed — with nothing injected the server binds 8123, the port the image exposes and health-checks.

A real deployment with Compose

skein up already generates a working compose.yaml (app + Postgres + Redis) into .skein/build. That file is regenerated on every skein up, so copy it out before editing:

bash
skein build -t my-agent
cp .skein/build/compose.yaml ./compose.yaml

Then adjust it for a real box — a strong database password, no published database ports, and pinned image tags:

yaml
services:
  app:
    image: my-agent
    init: true # reap zombies if your graphs spawn child processes
    ports:
      - "127.0.0.1:8123:8123" # only the reverse proxy reaches it
    environment:
      POSTGRES_URI: postgresql://postgres:${POSTGRES_PASSWORD}@postgres:5432/skein
      REDIS_URI: redis://redis:6379
      PG_POOL_MAX: "5"
      SKEIN_RUN_CONCURRENCY: "5"
      SKEIN_SHUTDOWN_GRACE_MS: "20000"
    depends_on:
      postgres: { condition: service_healthy }
      redis: { condition: service_healthy }
    restart: unless-stopped
    stop_grace_period: 30s # must exceed the drain window above

  postgres:
    # pgvector image only needed if langgraph.json sets store.index; postgres:16 is fine otherwise.
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: skein
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d skein"]
      interval: 5s
      retries: 10
    restart: unless-stopped

  redis:
    image: redis:7
    command: ["redis-server", "--maxmemory-policy", "noeviction"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      retries: 10
    restart: unless-stopped

volumes:
  pgdata:
bash
docker compose up -d

Run it as a service

restart: unless-stopped survives crashes; a systemd unit also survives reboots and gives you journalctl:

ini
# /etc/systemd/system/skein.service
[Unit]
Description=skein agent server
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/skein
EnvironmentFile=/srv/skein/.env
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0

[Install]
WantedBy=multi-user.target
bash
sudo systemctl enable --now skein

Run it without Docker

If the box has Node and you would rather not run a daemon, skip the image entirely — see Without Docker for what skein build --artifact-only produces and how skein start differs from the container entrypoint. Postgres and Redis still have to come from somewhere: managed services, or packages on the same box.

bash
# build anywhere with Node, ship the artifact
skein build --artifact-only
rsync -a .skein/build/ server:/srv/skein/

# on the server
cd /srv/skein && npm install --omit=dev
ini
# /etc/systemd/system/skein.service
[Unit]
Description=skein agent server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=skein
WorkingDirectory=/srv/skein
EnvironmentFile=/srv/skein/.env
ExecStart=/usr/bin/node /srv/skein/node_modules/skein-js/dist/index.js start
Restart=always
RestartSec=2
# SIGTERM drains in-flight runs before exiting; give it more than SKEIN_SHUTDOWN_GRACE_MS so systemd
# doesn't SIGKILL a run mid-flight. `skein start` force-exits 3s after that grace window regardless.
KillSignal=SIGTERM
TimeoutStopSec=30

[Install]
WantedBy=multi-user.target
bash
sudo systemctl enable --now skein
journalctl -u skein -f

skein start binds 127.0.0.1:8123 by default, which is what you want with Caddy or nginx in front on the same host. Deploying a new version is rsync + npm install --omit=dev + systemctl restart skein — migrations run on boot, so there is no separate step.

TLS and streaming

Terminate TLS in front and turn response buffering off, or SSE streams will appear to hang until each run finishes.

Caddy — the shortest path to automatic HTTPS:

caddyfile
agents.example.com {
	reverse_proxy 127.0.0.1:8123 {
		flush_interval -1 # stream immediately; required for SSE
	}
}

nginx:

nginx
location / {
    proxy_pass http://127.0.0.1:8123;
    proxy_http_version 1.1;
    proxy_buffering off;          # required for SSE
    proxy_cache off;
    proxy_read_timeout 3600s;     # skein sends no heartbeat frame
    proxy_set_header Connection "";
}

Verify

Run the verification sequence against your domain.

Operating it

  • Firewall. Publish 80/443 only. Bind the app to 127.0.0.1 as above, and never expose 5432 or 6379 — the Compose network already connects them internally.
  • Auth. A public VPS is the easiest place to forget that skein's auth is off by default. Configure auth.path, or keep the box behind a VPN or an authenticating proxy.
  • Backups. The Postgres volume is your entire state — threads, runs, checkpoints, store items. docker compose exec postgres pg_dump -U postgres skein | gzip > backup.sql.gz, on a schedule, stored off the box.
  • Updates. skein build -t my-agent && docker compose up -d. Migrations run on boot; there is no separate migrate step.
  • One box is one instance, so the cross-instance machinery (Scaling past one instance) never has to do anything — cancellation and the per-thread run guard stay entirely in-process, and you pay for neither.