gon server:dev
Provision a remote development environment on a VPS so devs with slow Docker Desktop (typically Windows / WSL2) can edit through VS Code Remote-SSH while builds, containers, and HMR run on the server.
When to use this
Use the server:dev:* family when local Docker Desktop is the bottleneck:
- Cold container starts take minutes on Windows because Docker Desktop / WSL2 9P bind mounts cripple Laravel's autoload stat()s
- Dev machines hit RAM / CPU ceilings running the full stack locally
- Pair-programming or staging-style preview URLs need to be reachable by clients without lokal tunneling
Production stays on gon server:setup + gon release. The dev
VPS is a separate machine with its own conventions.
Architecture
One VPS = one project. The whole team SSHes in as a shared dev user; one repository checkout, one
MySQL volume, one Vite dev server. Devs use VS Code Remote-SSH so editor + terminal + extensions all run on the
VPS — there is no local sync.
Internet↓ :443 (TLS, ACME)Traefik (Docker, /opt/traefik) ← acquires Let's Encrypt cert↓ routes by Host header via container labelsProject containers in /home/dev/projects/{project}• nginx → Host(`{domain}`)• app (php-fpm, source bind-mounted from /home/dev/projects/{project})• mysql (container, no public port — SSH tunnel for TablePlus)• redis• vite → Host(`vite.{domain}`) with HMR over wss• mailpit → Host(`mail.{domain}`)• horizon (optional)• scheduler Shared dev user (UID matches across team via SSH keys)• ~dev/.gon/config.json — shared GitHub bot PAT• ~dev/.local/bin/gon → /usr/local/bin/gon (symlink for non-login SSH)
Traefik binds host ports 80/443 directly (no host nginx layer — different from gon
server:setup). MySQL stays on the docker network only; lokal TablePlus reaches it through an
SSH tunnel.
server:dev:setup
One-time VPS bootstrap. Idempotent — re-runnable to pick up template fixes.
gon server:dev:setup 63.180.149.61 \--alias=dev01 \--email=ssl@example.com \--ghcr-token=ghp_xxxxx \--shared-pat=ghp_yyyyy
What it does (11 steps)
- Check SSH connectivity — auto-detects user from
~/.gon/servers.json(e.g.ubuntuon AWS,rooton Hetzner) - Detect OS
- Install Docker (idempotent, 10-min timeout for slow VPSes)
- Install
git,curl,unzipvia apt - Create shared
devuser; seed~dev/.ssh/authorized_keysfrom your local pubkey or a--dev-keysfile - Create
traefik-publicDocker network - Setup Traefik — directly on host ports 80/443 with ACME (no host nginx)
- GHCR login (private base images)
- Install gon-cli on the VPS via
install.shwithGON_GITHUB_TOKENenv (skips interactive prompts), then symlink~dev/.local/bin/gon→/usr/local/bin/gonso non-login SSH finds it - Seed
~dev/.gon/config.jsonwith the shared PAT (skipped without--shared-pat) - Save VPS entry in
~/.gon/servers.jsonwithrole: dev
Token requirements
--ghcr-token needs classic PAT with read:packages scope.
--shared-pat needs scopes the team agrees on for cloning private repos and pushing — usually
repo + read:packages. You can use the same classic PAT for both flags.
Refusing partial production VPS reuse
The dev setup expects ports 80/443 free for Traefik+ACME. If you point it at a server already running
server:setup + gon release (host nginx on 80/443), Traefik fails to bind. Use a fresh
VPS for dev (Hetzner CX22 ~€4/mo or AWS t3.small) — see setup-dev-vps
recipe.
Options
| Option | Description |
|---|---|
--alias |
Short label (used as --host=... downstream) |
--user |
SSH user for initial connection. Auto-detected from servers.json if
infra:aws-ec2 registered the host. Override for Amazon Linux (ec2-user) or
Debian (admin). |
--email |
Email for Let's Encrypt registrations |
--ghcr-token |
Classic PAT with read:packages for GHCR pulls |
--shared-pat |
Team-shared GitHub PAT seeded into ~dev/.gon/config.json |
--dev-keys |
Path to a file with one pubkey per line (alternative to manual server:dev:keys add
later) |
server:dev:add-project
Clone a project onto the dev VPS, configure its dev deployment, and start the stack via gon up on
the server.
gon server:dev:add-project myapp \--host=dev01 \--repo=rozklad/myapp \--domain=dev01.rozklad.dev \--vite-host=vite.dev01.rozklad.dev \--mailpit-host=mail.dev01.rozklad.dev
What it does (8 steps)
- SSH reachability check (as
devuser) - DNS pre-flight (informational warning) — resolves
domain,vite-host,mailpit-hostagainst the host IP. Continues on failure (ACME will retry once DNS propagates) but flags missing records loudly. - Git clone (or fetch + pull) into
/home/dev/projects/{project}using the operator's PAT, which is then scrubbed from.git/config - Patch
gon.jsonwith thedeploymentblock (kind: vps-dev,domain,vite_host,mailpit_host) — the marker thatgon upuses to branch into VPS dev mode - Generate
.envwith random APP_KEY, DB credentials, Vite HMR vars (VITE_DEV_HMR_HOST,VITE_DEV_HMR_PROTOCOL=wss,VITE_DEV_HMR_PORT=443). Permissions0644so PHP-FPM (www-data) can read it. - Run
gon installon the VPS — composer install +gon-coreclone + module/theme resolution. 30-min timeout. - Run
gon upon the VPS — seesdeployment.kind=vps-devand rendersdocker-compose.vps-dev.yml.twig. 15-min timeout. - Register project in
~/.gon/servers.jsonasenv: dev
Compose template differences
The dev VPS compose (docker-compose.vps-dev.yml.twig) differs from local Docker Desktop
(docker-compose.yml.twig) in three ways:
- Traefik labels —
entrypoints=websecure+tls.certresolver=letsencryptinstead of mkcert - MySQL container with named volume, no
ports:mapping - Vite container (node:22-alpine) running
yarn devwith HMR env vars exposed via Traefik
The project's vite.config.mts needs a small VPS dev branch reading VITE_DEV_HMR_HOST /
_PROTOCOL / _PORT. add-project prints the snippet at the end of its
summary; default gon-base templates ship with the branch already in place.
Options
| Option | Description |
|---|---|
--host |
Dev VPS alias or IP (must have role: dev in servers.json) |
--repo |
GitHub slug owner/name |
--branch |
Branch to check out (default main) |
--domain |
Public dev domain (e.g. dev01.example.com) |
--vite-host |
Vite HMR subdomain (default vite.{domain}) |
--mailpit-host |
Mailpit subdomain (default mail.{domain}) |
server:dev:keys
Manage labelled SSH keys in ~dev/.ssh/authorized_keys on a dev VPS — onboarding/offboarding
teammates without touching unrelated entries.
gon server:dev:keys list dev01gon server:dev:keys add dev01 --key=~/honza.pub --label=honzagon server:dev:keys add dev01 --key="ssh-ed25519 AAAA..." --label=petrgon server:dev:keys remove dev01 --label=petr
Each managed entry pair looks like:
# gon-key:honzassh-ed25519 AAAAC3NzaC1lZDI1NTE5...
Lines without the gon-key: marker are left untouched on remove/sync, so anything you put there
manually survives.
server:dev:ssh-config
Print a copy-paste-ready ~/.ssh/config block — VS Code Remote-SSH then sees the host as a
one-clicker.
gon server:dev:ssh-config dev01 # Add this block to ~/.ssh/config : Host dev01-devHostName 63.180.149.61User devStrictHostKeyChecking accept-newServerAliveInterval 30ServerAliveCountMax 3 Then connect with:ssh dev01-devcode --remote ssh-remote+dev01-dev /home/dev/projects/<project>
server:dev:info
Dev card with everything a developer needs to start working: URLs, SSH targets, VS Code Remote-SSH command, DB credentials and tunnel hint, container statuses.
gon server:dev:info myapp
Status comes from a live docker compose ps over SSH (best-effort — non-fatal when the VPS is
unreachable).
server:dev:remove
Tear down a project from a dev VPS. The VPS itself stays.
gon server:dev:remove myapp # Drops volumes + repo (with confirmation)gon server:dev:remove myapp --keep-volumes # Preserve DB volumegon server:dev:remove myapp --keep-repo # Preserve cloned repogon server:dev:remove myapp --yes # Skip confirmation
What gets removed
docker compose down -v(or without-vwhen--keep-volumes)/home/dev/projects/{project}(unless--keep-repo) — refuses to delete paths outside that prefix as a safety net- Project's
devenvironment entry in~/.gon/servers.json
Daily dev workflow
Once add-project finishes, devs reach the project through VS Code Remote-SSH and use the regular gon
commands on the VPS:
# Connect oncecode --remote ssh-remote+dev01-dev /home/dev/projects/myapp # In the Remote-SSH terminal:gon shell # interactive shell in app containergon artisan migrate # runs on VPSgon logs -f # tail container logsgon test # phpunitgon push # if pushing modules upstream (needs deploy key on VPS) # Edit .blade.php / .vue files in VS Code → browser auto-refreshes via wssgit checkout -b feature/foogit commit -am "..."git push # uses shared PAT from ~dev/.gon/config.json
No local Docker, no local gon-cli, no local PHP. Everything runs on the VPS; VS Code is just the editor.
Local DB access
MySQL has no public port — TablePlus / DataGrip connect through an SSH tunnel:
ssh -L 33060:127.0.0.1:3306 dev01-dev -N # In TablePlus:# Host: 127.0.0.1# Port: 33060# User: {project} (from `gon server:dev:info`)# Password: {generated}# Database: {project}
Multi-dev considerations
The shared dev user means one git working tree, one DB, one set of branches. Keep coordination
explicit:
- Git identity — set per-checkout via
git config user.name / user.emailon the VPS (no--global, since the user is shared). Or always pass--authoron commits. - Branches — only one branch can be checked out at a time. For parallel work spin up a second
project (different repo or different domain) via another
add-project. - Long-running work —
tmux/screenor VS Code Remote-SSH's persistent session keeps your shell alive across disconnects.
Verification checklist
After dev:setup + dev:add-project on a fresh DNS, a healthy environment looks like:
curl -I https://dev01.example.com # HTTP/2 200, Let's Encrypt R13 issuercurl -I https://vite.dev01.example.com/vite/client # HTTP/2 200, access-control-allow-origin: *gon server:dev:info myapp # all containers Upssh dev01-dev "gon --version" # 2.9.x
Troubleshooting
| Symptom | Cause + fix |
|---|---|
| SSH check fails on AWS | Default --user=root doesn't apply to AWS Ubuntu. Pass --user=ubuntu (or
use infra:aws-ec2 first, which writes root_user to
servers.json for auto-detection). |
| Browser shows TRAEFIK DEFAULT CERT | Either DNS hasn't propagated or another gon up on the VPS triggered
Traefik::ensureRunning() which overwrites the ACME-enabled compose with the local
mkcert one. The vps-dev branch in UpCommand already skips this, but stale
state may persist — restart system Traefik with cd /opt/traefik && sudo docker compose
up -d. |
| 400 Bad Request "Untrusted Host" | .env permissions blocked PHP-FPM (www-data) from reading it; Laravel fell
back to APP_ENV=production; TrustHosts middleware activated. add-project
writes 0644 to fix this. |
500 with "Permission denied" on storage/logs/laravel.log |
Project files are owned by host UID 1001, but www-data is UID
33. gon up in vps-dev mode chmod-s storage/ +
bootstrap/cache/ to ugo+rw automatically. |
Vite manifest not found |
Vite container crashed. Most common: fs.inotify.max_user_watches too low (default
8192). Bump on host: echo fs.inotify.max_user_watches=524288 | sudo tee
/etc/sysctl.d/99-gon-inotify.conf && sudo sysctl -p
/etc/sysctl.d/99-gon-inotify.conf |
CORS error blocked by CORS policy on vite.<domain> |
Project's vite.config.mts CORS allowlist doesn't include the dev VPS domain. The VPS
dev branch in vite config sets cors: true when VITE_DEV_HMR_HOST is
present. |
gon: command not found over SSH |
install.sh only prints a PATH hint — it doesn't append to ~/.bashrc.
dev:setup creates /usr/local/bin/gon symlink so non-login SSH finds it. If
missing, re-run dev:setup. |
Related
- Setup dev VPS for the team — end-to-end recipe
gon server— production deployment counterpartgon infra— provisions VPSes on AWS / DNS / cloud teardown