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 labels
Project 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)

  1. Check SSH connectivity — auto-detects user from ~/.gon/servers.json (e.g. ubuntu on AWS, root on Hetzner)
  2. Detect OS
  3. Install Docker (idempotent, 10-min timeout for slow VPSes)
  4. Install git, curl, unzip via apt
  5. Create shared dev user; seed ~dev/.ssh/authorized_keys from your local pubkey or a --dev-keys file
  6. Create traefik-public Docker network
  7. Setup Traefik — directly on host ports 80/443 with ACME (no host nginx)
  8. GHCR login (private base images)
  9. Install gon-cli on the VPS via install.sh with GON_GITHUB_TOKEN env (skips interactive prompts), then symlink ~dev/.local/bin/gon/usr/local/bin/gon so non-login SSH finds it
  10. Seed ~dev/.gon/config.json with the shared PAT (skipped without --shared-pat)
  11. Save VPS entry in ~/.gon/servers.json with role: 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)

  1. SSH reachability check (as dev user)
  2. DNS pre-flight (informational warning) — resolves domain, vite-host, mailpit-host against the host IP. Continues on failure (ACME will retry once DNS propagates) but flags missing records loudly.
  3. Git clone (or fetch + pull) into /home/dev/projects/{project} using the operator's PAT, which is then scrubbed from .git/config
  4. Patch gon.json with the deployment block (kind: vps-dev, domain, vite_host, mailpit_host) — the marker that gon up uses to branch into VPS dev mode
  5. Generate .env with random APP_KEY, DB credentials, Vite HMR vars (VITE_DEV_HMR_HOST, VITE_DEV_HMR_PROTOCOL=wss, VITE_DEV_HMR_PORT=443). Permissions 0644 so PHP-FPM (www-data) can read it.
  6. Run gon install on the VPS — composer install + gon-core clone + module/theme resolution. 30-min timeout.
  7. Run gon up on the VPS — sees deployment.kind=vps-dev and renders docker-compose.vps-dev.yml.twig. 15-min timeout.
  8. Register project in ~/.gon/servers.json as env: 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 labelsentrypoints=websecure + tls.certresolver=letsencrypt instead of mkcert
  • MySQL container with named volume, no ports: mapping
  • Vite container (node:22-alpine) running yarn dev with 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 dev01
gon server:dev:keys add dev01 --key=~/honza.pub --label=honza
gon server:dev:keys add dev01 --key="ssh-ed25519 AAAA..." --label=petr
gon server:dev:keys remove dev01 --label=petr

Each managed entry pair looks like:

# gon-key:honza
ssh-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-dev
HostName 63.180.149.61
User dev
StrictHostKeyChecking accept-new
ServerAliveInterval 30
ServerAliveCountMax 3
 
Then connect with:
ssh dev01-dev
code --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 volume
gon server:dev:remove myapp --keep-repo # Preserve cloned repo
gon server:dev:remove myapp --yes # Skip confirmation

What gets removed

  1. docker compose down -v (or without -v when --keep-volumes)
  2. /home/dev/projects/{project} (unless --keep-repo) — refuses to delete paths outside that prefix as a safety net
  3. Project's dev environment 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 once
code --remote ssh-remote+dev01-dev /home/dev/projects/myapp
 
# In the Remote-SSH terminal:
gon shell # interactive shell in app container
gon artisan migrate # runs on VPS
gon logs -f # tail container logs
gon test # phpunit
gon push # if pushing modules upstream (needs deploy key on VPS)
 
# Edit .blade.php / .vue files in VS Code → browser auto-refreshes via wss
git checkout -b feature/foo
git 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.email on the VPS (no --global, since the user is shared). Or always pass --author on 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 worktmux / screen or 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 issuer
curl -I https://vite.dev01.example.com/vite/client # HTTP/2 200, access-control-allow-origin: *
gon server:dev:info myapp # all containers Up
ssh 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