A secure, production-ready deployment of OpenCode on Railway with Tailscale network access. This setup provides a fully-featured AI coding agent accessible via Tailscale's mesh VPN and SSH, with zero public internet exposure required.
- Tailscale-Only Access: Zero trust networking β access your OpenCode server only via your Tailscale network
- Auto SSH Key Management: Automatic SSH key generation or custom key injection
- Password-Protected Web UI: Built-in authentication layer for the OpenCode web interface
- Persistent Storage: Workspace data persists across container restarts via Railway volumes
- Persistent Tailscale Identity: Same Tailscale IP across deployments (no zombie nodes)
- Pre-installed Development Tools: Complete toolchain including Node.js, Bun, Docker CLI, Git, and more
- Memory Monitoring: Optional auto-restart on memory thresholds or idle timeouts
- GitHub CLI Integration: Pre-authenticated
ghCLI for seamless GitHub workflows - Health Check Endpoints: Built-in monitoring for Railway deployment health
- Graceful Shutdowns: Proper signal handling for zero-downtime redeployments
Click the button below to deploy instantly:
-
Fork or clone this repository
git clone https://github.com/yourusername/railway-opencode-tailscale.git cd railway-opencode-tailscale -
Create a new Railway project
railway init
-
Configure environment variables (see Environment Variables)
-
Deploy
railway up
The OPENCODE_SERVER_PASSWORD variable is optional. When running with Tailscale-only access (recommended), authentication is handled by your tailnet membership:
# Via Railway CLI (optional - for password protection)
railway variables set OPENCODE_SERVER_PASSWORD="your-secure-password"
# Or via Railway Dashboard
# Project β Your Service β Variables β AddTailscale-only mode: If no password is set, the web UI is accessible to anyone in your Tailscale network (requires Tailscale authentication).
Choose one authentication method:
Generate a reusable, non-ephemeral auth key with tags for persistent node identity:
-
In your Tailscale admin panel:
- Go to Settings β Keys β Auth Keys
- Click Generate auth key
- Reusable: β Yes
- Ephemeral: β No (disabled for persistent identity)
- Tags: Add
tag:railway-opencode(see ACL Policy) - Pre-approved: β Yes (if device approval is enabled)
-
Set the environment variable:
railway variables set TAILSCALE_AUTH_KEY="tskey-auth-..." railway variables set TAILSCALE_HOSTNAME="railway-opencode"
-
On first deployment, the node will register with your tailnet. Subsequent deployments will reuse the same node identity and IP address.
For short-lived testing environments where IP stability isn't required:
-
Generate an ephemeral auth key in your Tailscale admin panel:
- Reusable: β Yes
- Ephemeral: β Yes (node auto-removes after 30-60 min offline)
-
Set the environment variable:
railway variables set TAILSCALE_AUTH_KEY="tskey-auth-..."
Note: Ephemeral keys create new nodes on each deployment with different IPs.
-
Deploy without
TAILSCALE_AUTH_KEY:railway up
-
Check Railway logs for the authentication URL:
railway logs
-
Visit the displayed URL to authorize the device
-
The container will automatically continue once authenticated
SSH is enabled by default. Configure key access:
Set your public key as an environment variable:
# Copy your public key
export TAILSCALE_SSH_PUBKEY="$(cat ~/.ssh/id_ed25519.pub)"
railway variables set TAILSCALE_SSH_PUBKEY="$TAILSCALE_SSH_PUBKEY"Optionally, also set the private key for outbound SSH:
export TAILSCALE_SSH_PRIVKEY="$(cat ~/.ssh/id_ed25519)"
railway variables set TAILSCALE_SSH_PRIVKEY="$TAILSCALE_SSH_PRIVKEY"If TAILSCALE_SSH_PUBKEY is not set, the container will:
- Generate a new ed25519 keypair on first startup
- Display the private key in the logs (copy this!)
- Display the public key in the logs
- Save both to
/var/lib/opencode/.ssh/
Use Tailscale ACLs to control access to your OpenCode Railway nodes:
- Go to Tailscale Admin Console β Access Controls
- Adapt the example from
tailscale-acl.hujson - Key configurations:
- Define
tag:railway-opencodefor your Railway nodes - Set
tagOwnersto control who can create tagged devices - Configure SSH access rules for the
opencodeuser
- Define
Example ACL snippet:
{
"tagOwners": {
"tag:railway-opencode": ["your-email@example.com"]
},
"acls": [
{
"action": "accept",
"src": ["your-email@example.com"],
"dst": ["tag:railway-opencode:22", "tag:railway-opencode:18080"]
}
],
"ssh": [
{
"action": "check",
"src": ["your-email@example.com"],
"dst": ["tag:railway-opencode"],
"users": ["opencode", "root"]
}
]
}If you previously used ephemeral keys or undeployed services, you may have zombie oc-* nodes in your tailnet. Use the cleanup utility:
# 1. Get a Tailscale API key
# Visit: https://login.tailscale.com/admin/settings/keys
# Create key with "Devices: Write" scope
# 2. Preview what will be deleted
export TAILSCALE_API_KEY="tskey-api-xxxx"
./tailscale-cleanup.sh --dry-run
# 3. Delete matching nodes
./tailscale-cleanup.shThe script matches nodes with names starting with oc- (e.g., oc-abc123).
| Variable | Required | Default | Description |
|---|---|---|---|
OPENCODE_SERVER_PASSWORD |
β No | β | Password for web UI authentication (optional with Tailscale) |
TAILSCALE_AUTH_KEY |
β No | β | Tailscale auth key (recommended: reusable + tagged) |
TAILSCALE_HOSTNAME |
β No | railway-opencode |
Hostname in Tailscale network (used for node identity) |
TAILSCALE_SSH_PUBKEY |
β No | β | SSH public key for authentication |
TAILSCALE_SSH_PRIVKEY |
β No | β | SSH private key for outbound connections |
ENABLE_SSH |
β No | true |
Enable SSH server (true/false) |
ENABLE_MONITOR |
β No | false |
Enable memory/idle monitoring (true/false) |
IDLE_TIME_MINUTES |
β No | 10 |
Idle time before auto-restart |
MEMORY_THRESHOLD_MB |
β No | 2000 |
Memory threshold for auto-restart (MB) |
GITHUB_TOKEN |
β No | β | GitHub CLI authentication token |
GIT_USER_NAME |
β No | β | Git global user.name |
GIT_USER_EMAIL |
β No | β | Git global user.email |
LOG_LEVEL |
β No | INFO |
Server log level (DEBUG/INFO/WARN/ERROR) |
RAILWAY_API_TOKEN |
β No | β | Railway API token for graceful restarts |
TAILSCALE_API_KEY |
β No | β | Tailscale API key (for cleanup script only) |
After deployment, Railway provides a public URL:
https://your-project-name.up.railway.app
If you configured OPENCODE_SERVER_PASSWORD, access requires authentication. If running in Tailscale-only mode (no password), the proxy allows direct access.
Connect via the Tailscale IP from any device in your tailnet:
-
Get the Tailscale IP from logs:
railway logs | grep "Tailscale IP"
-
Access OpenCode UI:
http://100.x.x.x:18080 # Replace with actual IP
Connect directly via SSH:
# With provided keys (Option A)
ssh opencode@100.x.x.x
# With auto-generated keys (Option B)
ssh -i ~/.ssh/railway_opencode opencode@100.x.x.xNote: SSH listens only on the Tailscale interface for security.
Important: This deployment uses in-memory session storage. Sessions will be lost when:
- The container restarts (redeploy, crash, or Railway sleep/wake cycle)
- Railway scales horizontally (if multi-instance in future)
Workaround: Use Basic Auth for scripts/API access, or re-authenticate after redeploys.
The login endpoint has built-in rate limiting:
- 5 attempts per 15-minute window per IP
- 429 response with
Retry-Afterheader when exceeded
- Passwords are compared using timing-safe operations to prevent timing attacks
OPENCODE_SERVER_PASSWORDis optional β when running in Tailscale-only mode, no password is required- Use a strong, unique password if you want additional authentication layer
- Consider using Basic Auth for automated access instead of session cookies
- Never commit private keys to version control
- Auto-generated keys are displayed once in logs β save them immediately
- For production, provide keys via Railway secrets (not environment variables)
- OpenCode binds to
127.0.0.1(localhost only) β not directly accessible - Proxy server adds authentication layer before forwarding to OpenCode
- Tailscale provides mesh VPN encryption for all traffic
- SSH is disabled by default β must explicitly enable with
ENABLE_SSH=true
Avoid setting these in plaintext environment variables in production:
TAILSCALE_SSH_PRIVKEYβ use Railway's secret file mounting instead- Consider using Railway's built-in secrets management for all sensitive values
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Railway Container β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β OpenCode Server β β
β β (Port 18081, internal) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Authentication Proxy β β
β β (Port 18080, public via Railway) β β
β β β’ Password authentication β β
β β β’ Session management β β
β β β’ WebSocket proxying β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Tailscale Daemon β β
β β (Userspace networking, no iptables) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β SSH Server β β
β β (Port 22, Tailscale interface only) β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
Tailscale Network (Tailnet)
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Devices (iPhone, Laptop, etc.) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Node.js: v20 (LTS) and v22 (latest) via FNM
- Bun: Latest stable release
- npm/npx: Via Node.js installations
- Git: Latest stable with LFS support
- git-filter-repo: For repository history rewriting
- GitHub CLI (gh): Pre-configured if
GITHUB_TOKENis set
- Tailscale: Mesh VPN client
- OpenSSH Server: Secure remote access
- curl: HTTP client
- ca-certificates: SSL/TLS certificates
- FFmpeg: Audio/video processing
- Docker CLI: Client-only for remote Docker hosts
- docker-compose: Via Docker CLI plugins
- build-essential: GCC, make, and build tools
- procps: Process monitoring (ps, top, etc.)
- iproute2: Network management tools
- unzip: Archive extraction
- Tailscale Only: SSH listens exclusively on the Tailscale interface (100.x.x.x), not 0.0.0.0
- No Password Auth: SSH requires key-based authentication only
- Mesh Encryption: All Tailscale traffic is encrypted with WireGuard
- ACLs: Use Tailscale ACLs to restrict access within your tailnet
- Non-root User: OpenCode runs as
opencodeuser (UID 1000) - Minimal Base: Built on
debian:bookworm-slim - No Docker Daemon: Client-only tools (no privileged container needed)
- Read-only Root: Where possible, filesystems are read-only
- Password Required: Web UI requires
OPENCODE_SERVER_PASSWORD - Session Management: HTTP-only cookies with 30-day expiration
- Basic Auth: Optional HTTP Basic Auth for API access
- Never Commit Keys: All authentication is via environment variables
- Tagged Auth Keys: Use reusable, non-ephemeral auth keys with tags for persistent identity
- Private Key Storage: If using auto-generated keys, save the private key immediately
- State Persistence: Tailscale node state (
/data/tailscale) persists across deployments β don't delete the Railway volume
# Check logs for errors
railway logs --tail 100
# Verify environment variables
railway variablesCommon causes:
- Invalid
TAILSCALE_AUTH_KEYformat - OpenCode server not starting (check for missing AI provider configuration)
- Health check endpoint not responding (
/global/healthon port 18081)
# Check Tailscale status in container
railway ssh
tailscale status
# If stuck, try re-authenticating
tailscale down
tailscale up --authkey=$TAILSCALE_AUTH_KEYIf your Tailscale IP changes on every deployment:
-
Check state directory exists:
railway ssh ls -la /data/tailscale/ # Should show: tailscaled.state, other state files -
Verify volume mount:
df -h | grep /data # Should show: /dev/... mounted on /data
-
Check logs for state detection:
railway logs # Should show: "Found existing Tailscale state" or "No existing Tailscale state found" -
Ensure auth key is NOT ephemeral: Ephemeral keys create new nodes each time. Use a reusable, non-ephemeral key with tags instead.
-
If state is corrupted: Temporarily move the state directory to force re-authentication:
railway ssh mv /data/tailscale /data/tailscale.backup # Redeploy to create fresh state
-
Verify Tailscale is connected:
tailscale ping 100.x.x.x
-
Check SSH is enabled:
railway variables get ENABLE_SSH # Should return: true -
Verify key permissions inside container:
ls -la /var/lib/opencode/.ssh/ # Should be: 600 for private key, 600 for authorized_keys
If the container is restarting due to memory limits:
# Enable memory monitoring for auto-restart
railway variables set ENABLE_MONITOR=true
railway variables set MEMORY_THRESHOLD_MB=1500Or upgrade your Railway plan for more memory.
-
Clear browser cookies for the Railway domain
-
Verify password is set correctly:
railway variables set OPENCODE_SERVER_PASSWORD="new-password" railway up
-
Check for typo in password (case-sensitive)
# Check if OpenCode process is running
railway ssh
ps aux | grep opencode
# Check proxy logs
cat /var/log/opencode-monitor.log 2>/dev/null || echo "No monitor logs"Data should persist in /data. If lost:
# Verify volume is mounted
railway ssh
df -h | grep /data
# Check workspace contents
ls -la /data/
# Check Tailscale state
ls -la /data/tailscale/Ensure railway.toml has the volume mount configured:
[deploy.volumeMounts]
mountPath = "/data"The /data volume stores:
- Workspace data (
/data/workspace) - OpenCode config (
/data/.config,/data/.local) - Tailscale node state (
/data/tailscale) β for persistent identity across deployments
Build and test locally with Docker:
# Build image
docker build -t opencode-railway .
# Run locally (for testing only β requires Railway env)
docker run -it \
-e OPENCODE_SERVER_PASSWORD=test \
-e TAILSCALE_AUTH_KEY=tskey-auth-... \
-p 18080:18080 \
opencode-railway- Fork the repository
- Create a feature branch
- Make your changes
- Test on Railway
- Submit a pull request
MIT License β see LICENSE for details.