Install guide

Installing SyncSSH

This guide covers three scenarios. Pick the one that matches where you are:


What is included

Workspaces, access, and delivery

SyncSSH is a workspace around a pull-based SSH key manager. The control plane does not proxy SSH sessions or replace your existing SSH server.

Organizations and roles

Each workspace has Owners, Admins, and Developers. Owners and Admins manage servers, invites, API keys, and the audit log; Developers manage their own public keys and can use servers that allow self-service.

Key targeting

A public key can be assigned to every active server in a workspace or to selected servers. The worker changes only the SyncSSH block in one account's authorized_keys.

Scoped API keys

Owners and Admins can create rotatable API keys with read_servers, write_keys, or admin_members scopes. The full secret is shown once.

Cloud-only additions

The cloud edition adds signed outbound webhooks, light/dark invite-email theming, and unlimited audit-log retention. The OSS edition keeps 30 days of audit history and does not expose webhook endpoints.


The three URLs that matter

Almost every deployment confusion in syncssh traces back to one of these being wrong for the scenario you're in.

Variable What it is Must be reachable from
APP_BASE_URL Backend URL baked into the install script and invite links. Every server you enroll + browsers (for invite links).
SYNC_BASE_URL URL baked into the agent's sync cron. Falls back to APP_BASE_URL if unset. Every target server. Set when the agent needs a different host than browsers.
VITE_INSTALL_BASE_URL URL the dashboard renders in the curl install command shown to the admin. Whoever is reading the dashboard — usually the same as APP_BASE_URL.

Rule of thumb: APP_BASE_URL, SYNC_BASE_URL, and VITE_INSTALL_BASE_URL should be the same value in 99% of deployments. They diverge only when the agent needs a different hostname than browsers — e.g. a docker-internal hostname vs. localhost.


Scenario A

Quickstart — Docker Compose

Use this to evaluate syncssh end-to-end without renting a VPS. The bundled testvps service is an SSH sandbox on the same docker network as the backend.

Requirements: Docker + Docker Compose v2.

1. Clone and configure

$ git clone https://github.com/syncssh/syncssh.git
$ cd syncssh
$ cp .env.example .env
# Edit .env: set any non-empty SECRET_KEY for dev
$ docker compose up -d

2. Create your first local account

For normal local tester onboarding, open http://localhost:5173 and use Sign up. POST /api/v1/auth/signup/ creates the user, organization/workspace, and OWNER membership together.

Email verification is mandatory. With the default console email backend, find the confirmation link in the backend logs and open it before logging in:

$ docker compose logs backend

createsuperuser is only for operator/Django-admin access; it does not create an organization membership. A terminal-created user can authenticate, but the dashboard's GET /api/v1/org/ returns 403 "Not a member of any organization" until the user is assigned to a workspace.

As a Docker Compose bootstrap alternative, run the following and replace admin with the username you chose:

$ docker compose exec backend python manage.py createsuperuser

$ docker compose exec backend python manage.py shell -c "
from django.contrib.auth import get_user_model
from organizations.models import Organization, OrganizationMembership, Role
user = get_user_model().objects.get(username='admin')
org, _ = Organization.objects.get_or_create(
    slug='local', defaults={'name': 'Local workspace'}
)
OrganizationMembership.objects.get_or_create(
    user=user, organization=org, defaults={'role': Role.OWNER}
)
print(f'{user.username} is an OWNER of {org.name}')
"

3. Try it

  1. 1. Add a server in the dashboard. Copy the install command shown — it will look like:
    curl -sL http://localhost:8000/install/<token>/ | bash
  2. 2. SSH into the bundled sandbox:
    ssh root@localhost -p 2222 — password is whatever you set as VPS_ROOT_PASSWORD (default: syncssh)
  3. 3.
    Inside the testvps container, replace localhost with backend in the curl URL (docker DNS resolves backend to the backend container), then run it:
    $ curl -sL http://backend:8000/install/<token>/ | bash
  4. 4. Back in the dashboard, add your SSH public key. Wait ~60 seconds.
  5. 5. On the sandbox: cat ~/.ssh/authorized_keys — your key appears inside the # === SYNCSSH BEGIN === block.

Why the URL swap in step 3?

The install URL is generated using APP_BASE_URL, which defaults to localhost:8000 for the browser. Inside the testvps container, localhost resolves to the container itself, not the backend. The swap is only needed for this one install fetch: docker-compose already defaults SYNC_BASE_URL to http://backend:8000, so the docker-internal hostname is baked into the agent's sync cron automatically and sync keeps working after install.


Scenario B

Enrolling a real external server

You want to enroll an actual VPS (AWS EC2, Hetzner, a homelab box) while the control plane runs on your laptop.

The problem: the VPS can't resolve backend or reach localhost. You need to expose the control plane over a public URL first.

Option 1 — Cloudflared tunnel recommended

# Install once
$ brew install cloudflared   # macOS
# or download from github.com/cloudflare/cloudflared/releases

# Start the tunnel while docker compose is running
$ cloudflared tunnel --url http://localhost:8000
  → https://random-words-1234.trycloudflare.com

Then edit .env with the printed hostname:

APP_BASE_URL=https://random-words-1234.trycloudflare.com
VITE_INSTALL_BASE_URL=https://random-words-1234.trycloudflare.com
ALLOWED_HOSTS=localhost,127.0.0.1,random-words-1234.trycloudflare.com
$ docker compose restart backend

The install URL in the dashboard now points at the tunnel. The VPS can curl it, and the cron worker can keep pulling over the tunnel indefinitely. Note: the free Cloudflared tunnel hostname changes on restart — fine for debugging, not for anything persistent.

Option 2 — ngrok

$ ngrok http 8000
  → https://abc123.ngrok-free.app

Set APP_BASE_URL and VITE_INSTALL_BASE_URL to the printed URL, same as Cloudflared above.

Option 3 — Tailscale

Put your laptop and the VPS on the same tailnet, then use your laptop's Tailscale IP or MagicDNS hostname:

APP_BASE_URL=http://your-laptop.tail12345.ts.net:8000
VITE_INSTALL_BASE_URL=http://your-laptop.tail12345.ts.net:8000
ALLOWED_HOSTS=localhost,127.0.0.1,your-laptop.tail12345.ts.net

Best option for ongoing dev against persistent VPSes — stable hostname, no public exposure, no tunnel to keep alive.

Don't forget ALLOWED_HOSTS

Django rejects any request whose Host: header isn't listed in ALLOWED_HOSTS. The tunnel hostname must be added or you'll get bare 400 responses. The value is the hostname only — no scheme, no port.


Scenario C

Production deployment

One server hosts everything behind a real domain with a real certificate.

internet
   │
   ▼
┌──────────────┐
│  reverse     │  nginx / Caddy / Traefik
│  proxy       │  terminates TLS on :443
└──────┬───────┘
       │ proxies to backend:8000
       ▼
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  backend     │──│  Postgres    │  │  Redis       │
│  (gunicorn)  │  │              │  │ (rate limits)│
└──────────────┘  └──────────────┘  └──────────────┘

.env

DEBUG=False
SECRET_KEY=...  # openssl rand -base64 64
ALLOWED_HOSTS=ssh.yourco.com

APP_BASE_URL=https://ssh.yourco.com
VITE_INSTALL_BASE_URL=https://ssh.yourco.com
CORS_ORIGINS=https://ssh.yourco.com

DB_HOST=postgres.internal
DB_NAME=syncssh
DB_USER=syncssh
DB_PASSWORD=...

# SMTP for invite emails
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.sendgrid.net
EMAIL_HOST_USER=apikey
EMAIL_HOST_PASSWORD=...
EMAIL_PORT=587
EMAIL_USE_TLS=True
DEFAULT_FROM_EMAIL=noreply@yourco.com

Reverse proxy — Caddy simplest TLS

ssh.yourco.com {
    handle /assets/* {
        root * /var/www/syncssh/frontend/dist
        file_server
    }
    handle /api/*      { reverse_proxy 127.0.0.1:8000 }
    handle /install/*  { reverse_proxy 127.0.0.1:8000 }
    handle /accounts/* { reverse_proxy 127.0.0.1:8000 }
    handle {
        root * /var/www/syncssh/frontend/dist
        try_files {path} /index.html
        file_server
    }
}

For nginx: set proxy_set_header X-Forwarded-Proto $scheme; explicitly — syncssh trusts that header to recognise HTTPS-terminated requests.

Production checklist

  • ✓ DEBUG=False — the app refuses to boot without a strong SECRET_KEY when debug is off.
  • ✓ HTTPS via reverse proxy. Plain HTTP exposes bearer tokens in transit.
  • ✓ Postgres with backups configured.
  • ✓ Redis-backed cache for rate limits. The default in-memory cache is per-worker and per-process — per-IP throttles weaken on every restart or scale-out.
  • ✓ APP_BASE_URL matches the public URL exactly (no trailing slash).
  • ✓ SMTP configured for invite emails — invites silently queue in the console backend if not set.

When DEBUG=False, syncssh automatically enforces SESSION_COOKIE_SECURE, CSRF_COOKIE_SECURE, SECURE_SSL_REDIRECT, HSTS with subdomains and preload, X_FRAME_OPTIONS=DENY, and SECURE_CONTENT_TYPE_NOSNIFF. You don't configure any of these — they're locked on automatically.


Common pitfalls

1. Agent installs but nothing ever syncs

Symptom: install completes, but cat ~/.ssh/authorized_keys never shows keys. Cron is silently failing on DNS.

Fix: SYNC_BASE_URL (falling back to APP_BASE_URL) must resolve from the target server. For a docker-network target, set SYNC_BASE_URL=http://backend:8000 while keeping APP_BASE_URL=http://localhost:8000 for browser-facing links.

2. Bare 400 "Bad Request" with no detail

Symptom: every request to the backend returns 400 with no useful body.

Fix: the tunnel/domain hostname isn't in ALLOWED_HOSTS. Add it (hostname only, no scheme, no port). Briefly set DEBUG=True to see Django's verbose error for the offending Host header, then set it back.

3. Dashboard shows the right URL but sync 404s

Symptom: dashboard shows curl pointed at host A; install completes; but the agent's sync calls hit host B and 404 forever.

Fix: APP_BASE_URL and VITE_INSTALL_BASE_URL diverged. Keep them identical.

4. Per-IP throttles "leak" after a deploy

Symptom: rate limits reset every time gunicorn restarts.

Fix: configure a Redis-backed cache. The default in-memory cache is per-worker and per-process.

5. Install URL replay returns 410

Symptom: re-running the install command prints syncssh-install: Install URL already used. and exits 1.

This is not a bug. Install URLs are one-shot — a leaked URL can't be replayed to recover the bearer token. Rotate the server token from the dashboard to re-arm.

6. Dashboard unreachable from another device on the LAN

Symptom: :5173 works on localhost but not from another machine.

Fix: Vite's dev server binds to 127.0.0.1 by default. Pass --host 0.0.0.0 to the frontend service in docker-compose.


Troubleshooting

Agent installed but nothing syncs

# On the target server
$ crontab -l
# Should show: * * * * * $HOME/.syncssh/sync-ssh.sh

# Run the worker manually with verbose output
$ bash -x ~/.syncssh/sync-ssh.sh
curl result Meaning Fix
DNS / connection error APP_BASE_URL not reachable from this host. Use a tunnel (scenario B) or a real public URL (scenario C).
401 Sync token is stale (rotated server-side). Rotate server in dashboard, re-run install.
410 Install URL replayed. Rotate server token, get new install URL, re-run.
200, no keys No keys assigned to this server, or key owner left the org. Add keys / restore membership.

Backend logs show nothing useful

$ docker compose logs -f backend

A bare "Bad Request (400)" with no detail almost always means ALLOWED_HOSTS. Briefly set DEBUG=True, reproduce the error, fix the offending Host header, then set DEBUG=False again.

Frontend shows "Network Error" on every API call

$ curl -i http://localhost:5173/api/v1/auth/csrf/
  • 200 → proxy is fine; check CORS_ORIGINS covers your dashboard origin.
  • 502 → backend is down; run docker compose ps and docker compose logs backend.
  • 404 → VITE_API_PROXY points at the wrong target.

Env var reference

Variable Used by Default Notes
SECRET_KEY backend dev fallback Sessions, CSRF, password reset signing. Rotating logs everyone out.
DEBUG backend False Set True for local dev. Security headers are automatically enforced when False.
ALLOWED_HOSTS backend localhost,127.0.0.1 Comma-separated hostnames the backend accepts. Must include every Host: header value.
APP_BASE_URL backend http://localhost:8000 Used in invite emails and install script generation. Must be browser-resolvable.
SYNC_BASE_URL backend inherits APP_BASE_URL Baked into the agent's sync cron. Set when targets need a different hostname than browsers (e.g. http://backend:8000 for docker-internal targets).
CORS_ORIGINS backend http://localhost:5173 Comma-separated. Doubles as CSRF_TRUSTED_ORIGINS.
DB_HOST / PORT / NAME / USER / PASSWORD backend compose defaults Postgres connection. Use a managed Postgres in production.
EMAIL_BACKEND backend console Switch to django.core.mail.backends.smtp.EmailBackend for production invite delivery.
DEFAULT_FROM_EMAIL backend noreply@syncssh.dev Visible to invite recipients.
VITE_API_PROXY frontend (dev) http://backend:8000 Where Vite proxies /api calls in dev. Build-time only, not runtime.
VITE_INSTALL_BASE_URL frontend http://localhost:8000 URL rendered in the dashboard's curl install command. Match APP_BASE_URL.
VPS_ROOT_PASSWORD testvps only syncssh Sandbox only. Never set in any env that touches production.

Found something missing or hit a scenario this guide doesn't cover? Open an issue — deployment edge cases are the highest-value contributions.