Install guide
Installing SyncSSH
This guide covers three scenarios. Pick the one that matches where you are:
- ▸ Quickstart — Docker Compose — bundled sandbox with a test SSH target. Five minutes, no infra.
- ▸ Enrolling a real external server — control plane on your laptop, target VPS somewhere on the public internet.
- ▸ Production deployment — self-hosted with TLS, real domain, Postgres + Redis.
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.
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.
Add a server in the dashboard. Copy the install command shown — it will look like:
curl -sL http://localhost:8000/install/<token>/ | bash -
2.
SSH into the bundled sandbox:
ssh root@localhost -p 2222— password is whatever you set asVPS_ROOT_PASSWORD(default:syncssh) -
3.
Inside the testvps container, replace
localhostwithbackendin the curl URL (docker DNS resolvesbackendto the backend container), then run it:$ curl -sL http://backend:8000/install/<token>/ | bash - 4. Back in the dashboard, add your SSH public key. Wait ~60 seconds.
-
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.
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.
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 strongSECRET_KEYwhen 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_URLmatches 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_ORIGINScovers your dashboard origin. - 502 → backend is down; run
docker compose psanddocker compose logs backend. - 404 →
VITE_API_PROXYpoints 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.