>_EXECUTIVE SUMMARY
zsh-nginx is the vhost-lifecycle plugin for the Debian/Ubuntu nginx layout: $NGINX_DIR/sites-available/ stores configs, $NGINX_DIR/sites-enabled/ is a symlink farm pointing into the available tree. Three autoloaded zsh functions (en, dis, vhost) plus two service aliases (ngt for config-test, ngr for restart) cover the standard "create vhost → enable it → reload nginx" loop. Each function ships a paired #compdef completion driven by ls over the relevant sites-* directory — completion candidates are always real on-disk files, never a hand-maintained list that can drift. The vhost generator sed-substitutes {vhost}/{user}/{pool_port} into one of two templates (Symfony2 fastcgi or plain PHP-FPM) and optionally writes an /etc/hosts entry — turning a new vhost into a single command.
Plugin Source — 246 LOC
Functions (autoload/en, dis, vhost) carry 147 LOC of the 246-LOC total. The vhost file is 102 LOC alone — it embeds its own usage banner, template resolution, getopts argument parsing, and the _write_hosts helper that splices a 127.0.0.1 mapping into /etc/hosts.
#FILE STRUCTURE
Three slices: autoload/ for the user-facing fns, src/ for the matching completions (one #compdef per fn), templates/ for the nginx config skeletons. The entrypoint registers both directories on fpath (autoload + completion) so a single source line covers the full surface.
| File | Lines | Role |
|---|---|---|
| autoload/vhost | 102 | Vhost generator + _vhost_usage, _vhost_generate, _write_hosts helpers; getopts -l/-u/-t/-n/-w/-h parser; sed-substitutes {vhost}/{user}/{pool_port} into the chosen template; optional en auto-call + /etc/hosts write |
| autoload/en | 25 | Enable site: ln -s $NGINX_DIR/sites-available/$1 $NGINX_DIR/sites-enabled/$1 with pre-check (must exist in available) and post-check (must materialise in enabled); colorized status output |
| autoload/dis | 20 | Disable site: rm -f $NGINX_DIR/sites-enabled/$1 with the same pre/post-condition contract as en |
| zsh-nginx.plugin.zsh | 21 | Entrypoint: $NGINX_DIR/$NGINX_VHOST_TEMPLATE defaults, sudo probe, ngt/ngr aliases, fpath append for both src/ and autoload/, autoload fn registration |
| templates/symfony2 | 27 | Symfony2 vhost template: web/ root, app_dev.php front controller, fastcgi-pass over unix:/var/run/php5-fpm.{user}.socket, .ht deny |
| templates/plain_php | 27 | Plain PHP vhost template: index.php root, simple try-files, fastcgi-pass over the same per-user PHP-FPM socket, .ht deny |
| src/_nginx_en | 8 | #compdef en: compadd with output of ls -a $NGINX_DIR/sites-available filtered by awk '/^[a-z][a-z.-]+$/' |
| src/_nginx_dis | 8 | #compdef dis: same shape but listing sites-enabled |
| src/_nginx_vhost | 8 | #compdef vhost: lists $HOME/www/ entries as candidates (the “repos that could become vhosts” set) |
| 9 source files | 246 | 3 fn + 1 entrypoint + 3 completions + 2 templates |
$USER SURFACE
Five callable names. The plugin lives in the user's interactive shell; nothing runs at source time except env-var defaults + the sudo probe + 2 alias definitions + fpath append + autoload -Uz. First call to en/dis/vhost triggers the actual fn body via zsh's autoload machinery.
| Name | Kind | Effect |
|---|---|---|
| ngt | alias | $sudo nginx -t — config syntax test |
| ngr | alias | $sudo service nginx restart — service restart |
| en NAME | fn | Symlink $NGINX_DIR/sites-available/NAME into $NGINX_DIR/sites-enabled/ |
| dis NAME | fn | Remove $NGINX_DIR/sites-enabled/NAME |
| vhost [-l] [-u USER] [-t TPL] [-n] [-w] [-h] NAME | fn | Generate a vhost config from $NGINX_VHOST_TEMPLATE (or -t TPL), drop into sites-available/NAME, optionally en it (-n to skip) and optionally splice NAME into /etc/hosts (-w) |
// VHOST FLAGS
| Flag | Default | Effect |
|---|---|---|
| -l | (off) | List enabled vhosts (ls $NGINX_DIR/sites-enabled); short-circuits before generation |
| -u USER | $USER | Set the owning user for fastcgi socket + web root resolution; looked up in /etc/passwd |
| -t TPL | $NGINX_VHOST_TEMPLATE | Template name (looks in $ZSH/plugins/nginx/templates/$TPL first, then accepts a raw path) |
| -n | (enable) | Generate-only; skip the trailing en auto-call |
| -w | (off) | Append a 127.0.0.1 NAME mapping to /etc/hosts for local-only DNS |
| -h | n/a | Print usage and return |
@VHOST PIPELINE
From vhost foo.example to a live nginx site in 5 stages. Each stage gates on the previous — missing user, missing template, or failed enable will short-circuit with a colourised error and leave the system in a consistent state (no half-written config in sites-available, no dangling symlink in sites-enabled).
vhost [-u USER] [-t TPL] [-n] [-w] NAME
│
▼
┌─────────────────────────┐
│ getopts: parse flags │
│ user ← USER || $USER│
│ tmpl ← TPL || $NGINX_VHOST_TEMPLATE
│ enable ← (no -n) │
│ hosts ← (-w) │
└─────────┬───────────────┘
│
▼
┌─────────────────────────┐
│ resolve template path │
│ $ZSH/plugins/nginx/ │
│ templates/$TPL │ ──── or ── raw path ── or ── default ─▶
└─────────┬───────────────┘
│
▼
┌─────────────────────────┐
│ _vhost_generate │
│ user lookup (passwd) │
│ pool_port = "1" + UID │
│ sed {vhost}{user}{pool_port}
│ → $NAME.tmp │
│ $sudo mv $NAME.tmp │
│ → sites-available/ │
└─────────┬───────────────┘
│
▼ (if enable)
┌─────────────────────────┐
│ en $NAME │
│ ln -s available/$NAME │
│ enabled/$NAME │
│ (sudo) │
└─────────┬───────────────┘
│
▼ (if -w)
┌─────────────────────────┐
│ _write_hosts $NAME │
│ splice "127.0.0.1 │
│ $NAME" into first │
│ line of /etc/hosts │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ ngt && ngr │ ← user-driven, not auto
│ (verify + reload) │
└─────────────────────────┘
The pool-port trick (pool_port = "1" + UID) is the canonical multi-tenant PHP-FPM convention: each user gets a fastcgi socket on their own port, derived from /etc/passwd UID. UID 1000 → pool 11000, UID 1001 → 11001, etc. The template emits the literal unix:/var/run/php5-fpm.{user}.socket string — the pool_port is substituted in for templates that use it.
&COMPLETION
All three completions follow the same 8-line shape: a #compdef directive, an internal helper that runs ls -a against the relevant directory and pipes through awk to filter to lowercase-prefixed names, then compadd `helper` to feed candidates into the completion engine. The point: completion sources are live filesystem state, not a hand-maintained list.
| Completion | Source dir | awk filter |
|---|---|---|
| _nginx_en | $NGINX_DIR/sites-available | /^[a-z][a-z.-]+$/ |
| _nginx_dis | $NGINX_DIR/sites-enabled | /^[a-z][a-z.-]+$/ |
| _nginx_vhost | $HOME/www | /^[^.][a-z0-9._]+$/ |
The asymmetry is intentional. en's candidates are configs that exist but aren't symlinked (available). dis's candidates are configs that are currently active (enabled). vhost's candidates are project directories that don't yet have a vhost (~/www). Each completion answers exactly the question the user asks; no overlap, no confusion.
~TEMPLATES
Two stock templates, both targeting per-user PHP-FPM with a unix-socket fastcgi backend. Both deny .ht* dotfiles (Apache leftovers blocked by convention). Substitution uses {vhost}, {user}, {pool_port} — a 3-key sed pass. The choice was deliberate: bigger templating systems (mustache, jinja) would force a tool dep on the user's box; sed is in every POSIX install.
templates/symfony2
Web root /home/{user}/www/{vhost}/web. Front controller app_dev.php. try_files $uri $uri/ /app_dev.php$uri /app_dev.php$is_args$args. Fastcgi handler scoped to ~ ^/(app|app_dev|check)\.php(/|$) — only the canonical Symfony2 entry scripts run PHP. client_max_body_size 10M. Per-vhost error + access logs at /var/log/nginx/{vhost}.{error,access}.log.
templates/plain_php
Web root /home/{user}/www/{vhost} (no nested web/). Front controller index.php. try_files $uri $uri/ $uri/index.php. Fastcgi handler scoped to any \.php$ — classic PHP layout with one entry per script. Same per-vhost logs and PHP-FPM socket as the Symfony template.
*TEST COVERAGE
28 zunit @test cases across 5 test files pin the plugin contract: aliases (ngt/ngr) are defined and reference $sudo; entrypoint defaults $NGINX_DIR and $NGINX_VHOST_TEMPLATE only when unset (idempotent re-source); every autoload fn is defined after sourcing the plugin; every completion has a #compdef directive matching its fn name; every source file parses cleanly under zsh -n. 21 additional .sh doc-hygiene gates pin the README, docs, workflow, and test-file structure.
| Test file | @test | Pins |
|---|---|---|
| tests/t-aliases.zsh | 6 | ngt + ngr alias presence; alias values reference $sudo; alias values mention nginx; sudo probe sets $sudo when sudo is on PATH |
| tests/t-contract2.zsh | 6 | Three autoload fns (en, dis, vhost) are defined after sourcing; autoload/ appears on fpath; src/ appears on fpath |
| tests/t-syntax.zsh | 6 | Every file under autoload/ and src/ + entrypoint parses under zsh -n; no syntax drift on push |
| tests/t-contract3.zsh | 5 | $NGINX_DIR defaults to /etc/nginx when unset; $NGINX_VHOST_TEMPLATE default; existing values are not clobbered on re-source |
| tests/t-contract4.zsh | 5 | Each completion file (_nginx_en, _nginx_dis, _nginx_vhost) starts with #compdef followed by the matching fn name; entrypoint stem matches plugin directory name |
| 5 zunit files | 28 | + 21 .sh doc-hygiene gates |
?KEY DESIGN DECISIONS
Each call-out is a decision the implementation could have gone either way on, with the rationale for the path taken.
Debian/Ubuntu layout, not Red Hat
The sites-available/ + sites-enabled/ split is a Debian convention — Red Hat puts every config in /etc/nginx/conf.d/. The plugin standardises on the Debian layout because that's where the symlink semantics make sense; en/dis have nothing useful to do on a flat conf.d setup.
Symlink, not move
en creates a ln -s, not a cp or mv. dis is rm -f against the symlink, not the underlying config. This means dis never destroys the actual file; the user can en again to instantly restore.
sed templating
3 placeholders, sed-substituted in a single pass. A real templating language would force a tool dep that may not be on the user's nginx host. sed is POSIX, and the 3-key surface is small enough that escaping concerns don't bite.
UID-derived pool port
pool_port = "1" + UID via /etc/passwd lookup. Avoids hand-assigning per-user PHP-FPM ports; the port is deterministic from the system's user database, so two users on the same box can't collide.
Completion from filesystem
Every #compdef reads ls -a against the relevant directory rather than maintaining a static list. A vhost added by hand outside the plugin still tab-completes; the plugin can never lie about what's actually installed.
Optional sudo
The entrypoint probes for sudo (which -p sudo) and falls back to empty string when missing. Containers, embedded systems, and root-shells get a working plugin without the sudo prefix; default Linux desktops get the elevation they need to touch /etc/nginx.
Two templates
One is the historical Symfony 2 layout (web/ root, fastcgi scoped to entry scripts), one is generic PHP (any .php executes). Together they cover the dominant LAMP / Symfony deployment shapes circa the plugin's origin. A user with another framework can drop a custom template into $ZSH/plugins/nginx/templates/ and select it with -t.
Inline ANSI escapes, not tput
Status messages embed raw \033[31m...\033[0m sequences. The plugin runs in interactive zsh on a terminal that supports them; portability to non-terminal contexts isn't a goal. tput would force a termcap/terminfo lookup that isn't needed.
+COMPAT & DEPS
The plugin assumes Debian-style nginx layout (/etc/nginx/sites-available + /etc/nginx/sites-enabled) and per-user PHP-FPM on unix sockets. Anything else is BYO template. Zsh 5.x compat floor — no zsh 6 features. No build step, no install hook, no compiled output.
| Dep | Hardness | Use |
|---|---|---|
| zsh | hard | Runtime (5.x+) |
| nginx | hard | Target service — the plugin's reason to exist |
| sed | hard | Template substitution in _vhost_generate |
| awk | hard | Completion filter (ls -a | awk ...) and /etc/passwd field extraction |
| sudo | soft | Auto-detected; falls back to direct exec when missing |
| service / systemctl | soft | ngr uses service nginx restart; on systemd-only boxes, customise the alias |
// ENV CONTRACT
| Env var | Default | Purpose |
|---|---|---|
| NGINX_DIR | /etc/nginx | Root for sites-available/ + sites-enabled/ |
| NGINX_VHOST_TEMPLATE | $ZSH/plugins/nginx/templates/symfony2 | Default template when vhost is called without -t |
!STRATEGIC POSITION
zsh-nginx is the smallest plugin in the MenkeTechnologies stack but covers a real ops loop: "I want a new local vhost on this dev box, now." Without the plugin, the same loop is ~5 hand-typed commands across cd /etc/nginx/sites-available, sudo cp template foo, sudo vim foo, sudo ln -s ../sites-available/foo ../sites-enabled/foo, and sudo service nginx restart. With the plugin it's vhost foo -w && ngt && ngr — same outcome, three commands, no manual sed.
Dev workflow
Spin up a vhost for a project in ~/www/foo: vhost foo -w generates the config, enables it, and writes 127.0.0.1 foo to /etc/hosts. ngt + ngr reload.
Multi-tenant servers
The UID-derived pool port + per-user fastcgi socket convention scales to dozens of users on a shared host without port collisions or hand-maintained pool mappings.
CI / provisioning
The plugin is a thin shim over ln -s + sed — safe to call from provisioning scripts. en/dis are idempotent by construction (the symlink either exists or it doesn't).
Plugin stack hub
One of 5 plugins in the MenkeTechnologies meta repo; the lightest in LOC but the one with the most external system touch (nginx config, /etc/hosts, /etc/passwd, service control).