---
title: "Deploy Skyvern"
description: "AI agents that drive a real browser to fill forms and scrape sites."
category: "AI/ML"
url: https://railway.com/deploy/skyvern
---

# Deploy Skyvern

AI agents that drive a real browser to fill forms and scrape sites.

**[Deploy Skyvern on Railway](https://railway.com/template/skyvern)**

- **Creator:** darseen
- **Category:** AI/ML
- **Total deploys:** 1

## Template content

### skyvern-ui https://github.com/Skyvern-AI.png?size=460

- **Image:** public.ecr.aws/skyvern/skyvern-ui:v1.0.51
- **Start command:** `sh -c 'cat > /tmp/skyvern-token.mjs <<"TOKEOF"
const res = await fetch(process.env.SKYVERN_CREDENTIAL_URL);
if (res.ok) process.stdout.write((await res.text()).trim());
TOKEOF
for i in $(seq 1 60); do
  KEY=$(node /tmp/skyvern-token.mjs 2>/dev/null || true)
  if [ -n "$KEY" ]; then
    export SKYVERN_API_KEY="$KEY"
    echo "picked up skyvern org API key"
    break
  fi
  echo "waiting for the skyvern API to mint its org key..."
  sleep 5
done
exec /bin/bash /app/entrypoint-skyvernui.sh'`
- **Health check:** /

### postgres https://devicons.railway.com/i/postgresql.svg

- **Image:** ghcr.io/railwayapp-templates/postgres-ssl:18

### proxy https://github.com/caddyserver.png?size=460

- **Image:** caddy:2-alpine
- **Start command:** `sh -c 'printf "%s" "$CADDYFILE" > /etc/caddy/Caddyfile && exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile'`
- **Health check:** /proxy-healthz
- **Public domain:** Yes

### skyvern https://github.com/Skyvern-AI.png?size=460

- **Image:** public.ecr.aws/skyvern/skyvern:v1.0.51
- **Start command:** `sh -c 'cat > /tmp/skyvern-aux.mjs <<"AUXEOF"

import { createServer } from "node:http";
import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
import { extname, resolve } from "node:path";

// Exactly the three directories that compose shares with the UI container.
// Anything wider is a public read primitive: /data also holds the file
// .skyvern/credentials.toml (the permanent org API key), the local credential
// vault and saved browser sessions. No apostrophes below this line: the whole
// script ships as a single-quoted sh -c argument.
const ROOTS = ["/data/artifacts", "/data/videos", "/data/har"];
const CREDS = process.env.SKYVERN_CREDENTIALS_FILE || "/data/.skyvern/credentials.toml";
const TYPES = {
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
  ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml",
};

function safePath(raw) {
  if (!raw) return null;
  const abs = resolve(raw);
  return ROOTS.some((root) => abs.startsWith(root + "/")) ? abs : null;
}

createServer((req, res) => {
  let url;
  try {
    url = new URL(req.url, "http://localhost");
  } catch {
    res.writeHead(400);
    return res.end("bad request");
  }
  const path = safePath(url.searchParams.get("path"));
  if (!path || !existsSync(path)) {
    res.writeHead(404);
    return res.end("not found");
  }
  if (url.pathname === "/artifact/recording") {
    const size = statSync(path).size;
    const start = req.headers.range ? Number(req.headers.range.replace(/\D/g, "")) : 0;
    const end = Math.min(start + 1000000, size - 1);
    res.writeHead(206, {
      "Content-Range": "bytes " + start + "-" + end + "/" + size,
      "Accept-Ranges": "bytes",
      "Content-Length": end - start + 1,
      "Content-Type": "video/mp4",
    });
    return createReadStream(path, { start, end }).pipe(res);
  }
  if (url.pathname === "/artifact/image") {
    res.writeHead(200, { "Content-Type": TYPES[extname(path).toLowerCase()] || "application/octet-stream" });
    return createReadStream(path).pipe(res);
  }
  if (url.pathname === "/artifact/json") {
    res.writeHead(200, { "Content-Type": "application/json" });
    return res.end(readFileSync(path));
  }
  if (url.pathname === "/artifact/text") {
    res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
    return res.end(readFileSync(path));
  }
  res.writeHead(404);
  res.end("not found");
}).listen(9090, "0.0.0.0");

createServer((req, res) => {
  let token = "";
  try {
    const found = readFileSync(CREDS, "utf8").match(/cred\s*=\s*"([^"]*)"/);
    if (found) token = found[1];
  } catch {}
  if (!token) {
    res.writeHead(503);
    return res.end("");
  }
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end(token);
}).listen(9091, "0.0.0.0");

AUXEOF
node /tmp/skyvern-aux.mjs &
exec /bin/bash /app/entrypoint-skyvern.sh'`
- **Health check:** /api/v1/heartbeat

## Documentation

# Deploy and Host Skyvern on Railway

Skyvern is an open-source AI agent that operates a real browser for you. Give it a plain-English goal and it navigates sites, fills forms, downloads files and extracts data, using an LLM plus computer vision instead of brittle XPath selectors that break whenever a page changes.

## About Hosting Skyvern

This template runs the four pieces Skyvern needs: a Postgres database, the API (which drives a headless Chromium under Xvfb), the React dashboard, and a small Caddy proxy that is the only service with a public domain. That single public origin is required rather than cosmetic: the dashboard mints its browser session through a same-origin request, so splitting the UI and API across two Railway domains breaks sign-in.

First boot is slow. The API image ships Playwright, Chromium and a full Tesseract language set, and it also runs database migrations and creates its organization before answering health checks, so allow several minutes. The dashboard waits for the API to mint its organization key and logs that it is waiting until it succeeds.

## Common Use Cases

- Automating logins, form submissions and multi-step checkout flows across sites that offer no API
- Scraping structured data from pages whose markup changes often enough to break selector-based scrapers
- Running scheduled workflows that download invoices, statements or reports from vendor portals

## Dependencies for Skyvern Hosting

- **An LLM API key (required).** Skyvern cannot run a task without one. The template is wired for OpenAI by default, so set `OPENAI_API_KEY` on the `skyvern` service. Anthropic, Gemini, Azure, Bedrock, OpenRouter, Groq and Ollama are supported by changing `LLM_KEY` and the matching `ENABLE_*` variable.
- **A volume**, provisioned automatically at `/data` on the `skyvern` service, holding artifacts, screenshots, recordings, browser sessions and the generated organization key.
- **Memory.** A headful Chromium is heavy. Expect to size the `skyvern` service well above a starter allocation for anything beyond light use.

### Deployment Dependencies

- [Skyvern on GitHub](https://github.com/Skyvern-AI/skyvern) (AGPL-3.0)
- [Skyvern documentation](https://docs.skyvern.com)
- [`public.ecr.aws/skyvern/skyvern`](https://gallery.ecr.aws/skyvern/skyvern) and [`public.ecr.aws/skyvern/skyvern-ui`](https://gallery.ecr.aws/skyvern/skyvern-ui)
- [Supported LLM providers](https://www.skyvern.com/docs/self-hosted/llm-configuration)

### Implementation Details

**One public origin.** Only the `proxy` service gets a domain. It routes by path, mirroring upstream's own Kubernetes ingress:

| Path | Upstream |
| --- | --- |
| `/api/*` | `skyvern:8000` |
| `/v1/*` | `skyvern:8000` |
| `/artifact/*` | `skyvern:9090` |
| everything else | `skyvern-ui:8080` |

The `/v1` route is not redundant. The dashboard strips a leading `/api` from its websocket and Runs API base URLs, so `wss://host/api/v1` is actually dialled as `wss://host/v1` and still has to reach the API.

**Two handoffs that compose does with shared volumes.** Upstream's `docker-compose.yml` bind-mounts the same host directories into both containers, which Railway cannot do because a volume attaches to exactly one service. Both moved onto the private network instead, served by a small Node script (Node is already present in the API image) started before the real entrypoint:

- Port `9090` replaces upstream's `artifactServer.js` so screenshots and recordings render in the dashboard. Unlike upstream's version, it resolves the caller-supplied `?path=` against an allowlist of `/data/artifacts`, `/data/videos` and `/data/har`. This port is reachable through the public proxy, and an unguarded file server there would expose the organization API key sitting in `/data/.skyvern/credentials.toml`.
- Port `9091` hands the generated organization key to the dashboard, which polls for it at startup. It is never routed by the proxy, so it stays private to the project.

**Ports are pinned.** Railway probes health checks on the canonical `PORT`, while the API binds `settings.PORT` and the dashboard's `localServer.js` hardcodes `8080`. Both services therefore set `PORT` explicitly so the probe and the process agree.

**Credentials survive redeploys.** `SKYVERN_CREDENTIALS_FILE` is moved onto the volume at `/data/.skyvern/credentials.toml`. At its default path the file would be lost on every redeploy, and the API would create a fresh organization each time while the dashboard kept using a stale key.

## Why Deploy Skyvern on Railway?


Railway is a singular platform to deploy your infrastructure stack. Railway will host your infrastructure so you don't have to deal with configuration, while allowing you to vertically and horizontally scale it.

By deploying Skyvern on Railway, you are one step closer to supporting a complete full-stack application with minimal burden. Host your servers, databases, AI agents, and more on Railway.



## Similar templates

- [Chat Chat](https://railway.com/deploy/-WWW5r) — Chat Chat, your own unified chat and search to AI platform.
- [stella](https://railway.com/deploy/stella) — Self-host stella with web, API, Postgres, Redis, and object storage.
- [Hermes Agent | OpenClaw Alternative with Dashboard](https://railway.com/deploy/hermes-agent-or-openclaw-alternative-wit) — Self-Hosted Hermes AI Agent for Telegram, Discord & Slack

Open this page in a browser: https://railway.com/deploy/skyvern
