gon server

Provision and manage VPS servers for GON deployments.

Architecture

Each gon server runs a stack of cooperating services. Knowing the layout makes the failure modes far easier to debug:

Internet
:443 (TLS)
host nginx /etc/nginx/sites-enabled/gon-{project}.conf
proxies to 127.0.0.1:8080
Traefik (Docker) /opt/traefik/docker-compose.yml
routes by Host header via container labels
Project containers
nginx (in traefik-public network)
app (php-fpm)
redis
horizon (optional)
scheduler
 
Host services (shared by all projects):
Docker daemon
MySQL (bound to 0.0.0.0; containers reach it via host.docker.internal)
certbot (issues + auto-renews Let's Encrypt certs for nginx vhosts)

Containers reach MySQL through Docker's host.docker.internal magic hostname, which resolves to the docker bridge IP (typically 172.17.0.1). The MySQL grants created by server:add-project pin each project user to 'localhost' + '172.%' so even with bind-address 0.0.0.0, only the docker bridge subnet can authenticate.

server:setup

One-time server initialization. Idempotent — safe to re-run after gon-cli upgrades to pick up template fixes for the system-level pieces.

gon server:setup 142.93.174.86 \
--alias=vps1 \
--user=root \
--email=ssl@example.com \
--ghcr-token=ghp_xxxxx

What it does (9 steps)

  1. Check SSH connectivity
  2. Detect OS
  3. Install Docker (via get.docker.com)
  4. Install system packages: mysql-server, nginx, certbot, python3-certbot-nginx
  5. Configure MySQL bind-address to 0.0.0.0 so containers can reach it through the docker bridge
  6. Create deploy user (in docker group, your local SSH key copied over)
  7. Create traefik-public Docker network
  8. Setup Traefik reverse proxy (host ports 8080/8443)
  9. GHCR login (optional — backfill later via server:ghcr-login if skipped)

Sudo-aware (works on AWS, GCP, Azure)

Modern cloud Ubuntu AMIs disable root SSH by default — you log in as ubuntu / ec2-user / admin with NOPASSWD sudo. Pass that as --user and gon-cli automatically wraps every privileged command (apt install, systemctl, /etc/nginx writes) with sudo -n bash. Servers configured for direct root SSH (Hetzner, DigitalOcean classic) keep working bit-identically — the wrapper is a no-op when --user=root.

If you skip --ghcr-token, the server boots without GHCR credentials and the first gon deploy will fail with error from registry: unauthorized. Backfill anytime with gon server:ghcr-login --host=vps1 — no need to re-run setup.

server:add-project

Prepare server for a project: MySQL database, nginx vhost, SSL, Docker config.

gon server:add-project myapp \
--host=vps1 \
--domain=myapp.example.com

Vhost conflict pre-flight

Before any mutation runs, gon-cli checks whether another project on the same host already serves the requested domain. Two nginx vhosts with the same server_name make nginx pick the alphabetically-first one and silently ignore the rest — a footgun that surfaces as 502s through the older un-buffered vhost. The pre-flight refuses to proceed and prints the exact remediation:

$ gon server:add-project gon --host=vps1 --domain=app.example.com --env=staging
 
Domain "app.example.com" is already served by another project's nginx vhost on vps1:
/etc/nginx/sites-enabled/gon-oldapp.conf
 
Two vhosts for the same server_name make nginx pick the
alphabetically-first one and silently ignore the rest →
buffer / SSL config drift, eventually 502 on the live site.
 
To resolve, retire the old project first:
gon server:remove-project oldapp --host=vps1 --keep-ssl --keep-db
 
--keep-ssl: hold the existing Let's Encrypt cert so the new vhost can reuse it
--keep-db: leave the old project's database untouched (drop it later if unused)
 
Or pass --force-vhost to overwrite anyway (advanced — risk of orphaned SSL/DB).

The current project's own gon-{project}.conf is excluded from the check, so idempotent re-runs of the same project don't trip it. Pass --force-vhost if you really want to leave both vhost files on disk (advanced — typically only useful when manually migrating SSL between vhosts).

SSL diagnostics

The certbot step passes -m EMAIL from the server config (saved by server:setup as acme_email) so --non-interactive mode can register a fresh ACME account. When certbot fails, gon-cli surfaces the actual reason in the summary table (parsed from certbot's Detail: line — typically DNS not propagated, HTTP-01 challenge timeout, or rate-limit) and prints a copy-pasteable retry command using the correct SSH user:

SSL retry once DNS for app.example.com resolves to 1.2.3.4:
ssh ubuntu@1.2.3.4 "sudo certbot --nginx -d app.example.com -m you@example.com --non-interactive --agree-tos --redirect"
Tip: dig +short A app.example.com @1.1.1.1 # confirm the A record before retrying

Multiple environments per project

Pass --env=NAME to register a non-production environment of the same project on a different host. The first time you do this on a project, gon-cli migrates the existing flat record into a nested environments map, then appends the new env alongside.

# Production (default — equivalent to --env=production)
gon server:add-project myapp \
--host=vps1 \
--domain=myapp.example.com
 
# Staging on a different host
gon server:add-project myapp \
--host=staging-1 \
--domain=staging.myapp.example.com \
--env=staging

After this, gon release / gon deploy / gon env:set/push/pull all accept the env name as the routing argument:

gon release staging # builds + deploys to staging-1
gon env:set DEBUG=true staging # sets DEBUG on staging's .env only

Each environment is independent: its own /opt/myapp directory, its own MySQL database, its own SSL certificate. Tearing one down with gon server:remove-project myapp --env=staging leaves the others untouched. Full walk-through with migration tips: multi-env recipe.

Options

OptionDescription
--hostServer alias or IP (must already be set up via gon server:setup)
--domainDomain to bind for this environment
--envEnvironment name (default production). Multiple envs per project are stored side by side under environments[name].
--horizonEnable Laravel Horizon worker container
--no-sslSkip Let's Encrypt certificate issuance
--no-dbSkip MySQL database / user creation
--force-vhostBypass the "domain already served by another project" pre-flight check (advanced — may leave orphaned SSL / vhost)

server:auth

Toggle HTTP Basic Auth for staging/pre-launch.

gon server:auth myapp --enable
gon server:auth myapp --disable

server:ghcr-login

Register or refresh GitHub Container Registry credentials on a server so docker can pull private ghcr.io/rozklad/gon-* images. Use this when the server was set up without --ghcr-token and gon deploy fails with error from registry: unauthorized, or when your PAT has rotated.

gon server:ghcr-login --host=vps1 # Prompts for the PAT (hidden input)
gon server:ghcr-login --host=vps1 --token=ghp_xxx # Pass the PAT explicitly
gon server:ghcr-login --all --token=ghp_xxx # Push the same PAT to every registered server

The login runs as the deploy user (placed in the docker group during server:setup), so credentials land in /home/deploy/.docker/config.json — exactly where docker compose looks during a pull.

PAT requirement: GHCR pulls only accept classic personal access tokens with the read:packages scope. Fine-grained tokens are rejected by the registry. Create a PAT at https://github.com/settings/tokens/new?scopes=read:packages&description=gon-cli.

server:remove-project

Tear down a deployed project from a server: stops containers, drops database, removes nginx vhost, SSL cert, and project directory.

gon server:remove-project myapp # Default env (with confirmation)
gon server:remove-project myapp --env=staging # Tear down only staging
gon server:remove-project myapp --force # Skip confirmation
gon server:remove-project myapp --keep-db # Keep database and user
gon server:remove-project myapp --keep-ssl # Keep SSL certificate
gon server:remove-project myapp --host=vps1 # Override saved host

What gets removed

  1. Docker containers and volumes
  2. Nginx vhost configuration
  3. SSL certificate (unless --keep-ssl)
  4. MySQL database and user (unless --keep-db)
  5. Project directory (/opt/{project})
  6. The targeted environment's entry in ~/.gon/servers.json

Multi-environment projects

For projects with several environments, --env=NAME narrows the teardown to that one environment only — other envs stay registered and running on their own hosts. Without --env, the project's default environment is removed. If that was the last remaining env, the project record is unregistered entirely. To wipe a multi-env project completely, repeat the command for each env.

server:upgrade

Sync per-project Docker configuration files to a running server without a full deploy. Useful for applying infrastructure changes (e.g., new docker-compose.yml template, nginx config updates).

gon server:upgrade myapp # Sync config files only
gon server:upgrade myapp --restart # Sync + restart containers
gon server:upgrade myapp --host=vps1 # Override saved host

What gets synced

  1. docker-compose.yml — regenerated from latest gon-cli template
  2. docker/nginx-production.conf — latest nginx config (project's IN-CONTAINER nginx)

Note: gon deploy also syncs these files automatically before each deployment. For HOST-level Traefik config see server:upgrade-traefik.

server:upgrade-traefik

Re-render and redeploy the system-level Traefik docker-compose.yml on a server. Use this to pick up gon-cli template fixes that server:setup wouldn't apply on its own (setup skips Traefik when /opt/traefik/docker-compose.yml already exists, which is the right call for first-time idempotency but means template patches never reach existing servers without an explicit redeploy path).

gon server:upgrade-traefik vps1 # Re-render + force-recreate container
gon server:upgrade-traefik vps1 --no-restart # Sync file only, don't recreate
gon server:upgrade-traefik vps1 --email=new@example.com # Override stored acme_email

What it does

  1. Backs up the existing /opt/traefik/docker-compose.yml to .bak (recoverable in seconds)
  2. Renders the bundled traefik/docker-compose.yml.twig with the server's stored acme_email
  3. Uploads the new compose under sudo (the dir is root-owned)
  4. docker compose up -d --force-recreate — required so changed CLI flags take effect even when the image hash hasn't changed
  5. Polls Traefik for up to 30s to confirm it reaches Up state, otherwise fails with a "check the logs" hint

When to run

After a gon-cli release that mentions Traefik changes (image bump, command flags, port pinning), or when gon server:status reports Traefik in a restart loop. Common symptoms that mean you need this:

  • 502 Bad Gateway on every project on the host while project containers themselves report healthy. Means host nginx can reach port 8080 but Traefik isn't listening — usually a Traefik startup crash you'd see in docker compose logs traefik.
  • 404 from Traefik on a Host header that should match an existing project. Traefik is up but its Docker provider can't see container labels, often because of a Docker SDK ↔ daemon API version mismatch (Docker 25+ rejects pre-v3.6 Traefik clients).
  • "bind: address already in use" in Traefik logs — earlier templates exposed both an explicit web entrypoint and the implicit dashboard one on the same port.

server:status

gon server:status # All servers
gon server:status vps1 # Detailed status