How to deploy Phantom to Specter-provisioned VMs using the Docker Hub image. This is the deployment method for Phantom Cloud and all new VMs going forward.
Specter provisions VMs with Docker, Caddy (TLS), and a placeholder specter-agent systemd service. The deploy script replaces the placeholder with the real Phantom running as a Docker container from ghostwright/phantom on Docker Hub. No git clone, no bun install, no building from source.
Three containers run on each VM:
- phantom - the AI agent (from Docker Hub)
- phantom-qdrant - vector memory database
- phantom-ollama - local embedding model (nomic-embed-text)
Caddy reverse-proxies https://<name>.ghostwright.dev to localhost:3100. This works unchanged because Docker maps port 3100 from the container to the host.
- A Specter VM (any size,
specter deploy <name> --server-type cx53 --location fsn1 --yes) - Root SSH access to the VM (all Specter VMs allow root SSH)
- An
.env.<name>file with the user's tokens (see "Create the Env File" below) - The
docker-compose.user.yamlfile from the Phantom repo
Location: scripts/deploy-to-specter-vm.sh
./scripts/deploy-to-specter-vm.sh <vm-ip> <env-file> [phantom-name]
# Example:
./scripts/deploy-to-specter-vm.sh <your-vm-ip> .env.<name> <name>What it does (5 steps, all idempotent):
- Stops specter-agent - SSHes as root, stops and disables the systemd service, removes the service file. Safe to run even if specter-agent is already gone.
- Copies files - SCPs
docker-compose.yamland.envto/home/specter/phantom/on the VM. - Cleans existing state - Runs
docker compose downto tear down any existing containers AND networks. This is critical: partial starts can leave containers on orphaned networks. Always tear down before starting. - Starts the stack - Runs
docker compose up -d. All three containers start together on the same Docker bridge network (phantom_phantom-net). Images are pulled from Docker Hub on first run. - Waits for health - Polls
localhost:3100/healthevery 5 seconds for up to 120 seconds. On first run, the bootstrap takes 30-60 seconds (Qdrant ready, Ollama ready, embedding model pull ~270MB, config init).
Create .env.<name> in the Phantom repo root with:
ANTHROPIC_API_KEY=sk-ant-...
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
OWNER_SLACK_USER_ID=U04ABC123
PHANTOM_NAME=<name>
Optional:
PHANTOM_MODEL=claude-sonnet-4-6 # Default. Use claude-opus-4-6 for max capability.
PHANTOM_DOMAIN=ghostwright.dev # Enables public URL: https://<name>.ghostwright.dev
The PHANTOM_MODEL env var overrides the model at both init time (written to phantom.yaml) and runtime (loadConfig reads it). Changing the model only requires updating .env and restarting the container.
Each Phantom instance needs its own Slack app. See docs/deploy-checklist.md Step 2 for the full manifest and setup instructions. The short version:
- api.slack.com/apps > Create New App > From a manifest
- Use the YAML manifest from
slack-app-manifest.yaml(change the name) - Install to workspace
- Copy Bot Token (xoxb-...) from OAuth and Permissions
- Create App-Level Token (xapp-...) with
connections:writescope - Get the user's Slack member ID
Specter provisions generic VMs. It installs Docker, Caddy, creates the specter user, sets up firewall rules, and runs a placeholder health endpoint. The deploy script handles the Phantom-specific setup:
- Specter owns: VM creation, DNS, TLS, Docker installation, firewall
- Phantom deploy script owns: stopping the placeholder, copying compose/env, starting containers
This separation means we never need to change Specter's cloud-init templates (which are frozen and require 3 deploy-test-destroy cycles to validate). All Phantom-specific logic lives in our deploy script.
The only Specter artifact we touch is specter-agent.service, which we remove. This is safe because:
- It's a 15-line Bun health check placeholder, not a real service
- Removing it is idempotent (the script checks before acting)
- Docker containers with
restart: unless-stoppedhandle process management going forward
Docker is enabled as a systemd service (systemctl is-enabled docker = enabled). On reboot:
- Docker daemon starts automatically
- Docker restarts all three containers (restart policy:
unless-stopped) - Phantom entrypoint runs: waits for Qdrant, waits for Ollama, verifies embedding model, starts the agent
- Caddy is already enabled and starts automatically, proxying to localhost:3100
specter-agent does NOT restart because the deploy script disabled it and removed the service file.
When a new version is tagged and pushed to Docker Hub:
ssh specter@<IP> "cd /home/specter/phantom && docker compose pull phantom && docker compose up -d phantom"This pulls the new image and restarts only the phantom container. Qdrant and Ollama continue running. Config, data, memory, and evolved state are preserved (they live on Docker volumes).
for vm in "<ip-1>:.env.<name-1>:<name-1>" \
"<ip-2>:.env.<name-2>:<name-2>" \
"<ip-3>:.env.<name-3>:<name-3>"; do
IFS=':' read -r ip env name <<< "$vm"
echo "=== Deploying $name ==="
./scripts/deploy-to-specter-vm.sh "$ip" "$env" "$name"
echo ""
donefor ip in <ip-1> <ip-2> <ip-3>; do
echo "=== Updating $ip ==="
ssh specter@$ip "cd /home/specter/phantom && docker compose pull phantom && docker compose up -d phantom"
echo "=== Done $ip ==="
doneThe specter-agent placeholder is still running. The deploy script handles this automatically, but if you need to do it manually:
ssh root@<IP> "systemctl stop specter-agent && systemctl disable specter-agent && rm -f /etc/systemd/system/specter-agent.service && systemctl daemon-reload"This happens when containers are started in separate docker compose up invocations (e.g., after a failed first attempt). Fix: docker compose down then docker compose up -d. The deploy script always does this.
First-run bootstrap can take 60-90 seconds (embedding model download is ~270MB). If it times out:
ssh specter@<IP> "docker logs phantom 2>&1 | tail -30"Common causes:
- Qdrant not ready (entrypoint waits 60s, then starts in degraded mode)
- Ollama not ready (entrypoint waits 60s, then starts without embeddings)
- Embedding model still downloading (watch the "Model pull:" log lines)
Fixed in v0.15.0. The runtime catches "No conversation found" errors from stale SDK sessions and retries as a fresh session. DMs use thread-scoped sessions, so new messages after a restart create new sessions automatically.
When a VM is rebuilt on a recycled IP:
ssh-keygen -R <IP>Production VM pool is tracked internally. Use specter list to see active VMs.
Docker (dev) = built from source on the VM. Docker (Hub) = pulled from ghostwright/phantom on Docker Hub. All new deploys use Docker (Hub).