Skip slow Git worktree walks on /mnt, run Vite on Windows npm, reuse Windows SSH keys, and stop rewriting the klevze-owned public app symlink that skinbase cannot replace.
11 KiB
Deployment
This repository uses a Bash-based production deploy flow with a Windows Command Prompt wrapper.
Normal deploy
Preferred entrypoints:
deploy.cmd
bash deploy.sh
deploy.cmd is the Windows Command Prompt entrypoint. It switches into WSL and runs deploy.sh.
deploy.sh is the canonical bash entrypoint and delegates to the safe production deploy wrapper, which stages each deploy into a versioned release directory and switches traffic by updating the server-side current symlink.
bash sync.sh remains as a legacy alias for the same flow.
deploy.cmd runs npm.cmd run build on Windows first, then enters WSL for rsync/ssh. That is required when the Ubuntu distro cannot execute Windows .exe files (Exec format error on powershell.exe). If WSL interop does work, bash deploy.sh can still launch npm.cmd through PowerShell. Local Linux php/composer are not required for a normal deploy; Artisan and Composer run on the production server. --with-tests uses WSL php when present, otherwise Windows php.exe.
This will:
- build frontend assets locally with
npm run build - stage the new code into a versioned release directory on the production server
- run
composer install --no-devinside that staged release before switching traffic - keep the fixed production app path pointed at the active release through a server-side
currentsymlink - bring the app down only for the short critical section that switches the active release and runs
php artisan migrate --force,php artisan optimize:clear, andphp artisan optimize - bring the app back up immediately after that critical section finishes
- warm the guest homepage cache with
php artisan homepage:warm-guest-cache - warm the post trending cache with
php artisan posts:warm-trending - restart queue workers with
php artisan queue:restart
This is now the low-downtime default path for normal code and feature deploys.
Each deploy generates:
- a monotonic local build number (stored in
var/deploy/build-number) - a release ID from UTC time + build number + Git revision, for example
20260829-141522-b42-a1b2c3d - local deploy history under
var/deploy/(latest.json,history.jsonl, and per-run logs invar/deploy/logs/) - remote metadata under
REMOTE_RELEASE_ROOT/deployments/<release-id>.jsonandcurrent-release.json
Console output includes step progress with elapsed time, rsync transfer progress, and a final duration summary. Override the build number with --build-number N or BUILD_NUMBER=N when needed. Dry-runs preview the next build number without consuming it.
Releases are retained under REMOTE_RELEASE_ROOT/releases/<release-id>, and production switches between them on the server by updating REMOTE_RELEASE_ROOT/current. The public/runtime path stays fixed at REMOTE_FOLDER, which is now treated as a stable symlink to the active release.
On the first deploy with this layout, the existing live folder is adopted into the release archive automatically and REMOTE_FOLDER is converted into that stable symlink path. After that, switching back to an older release does not require any local re-upload.
Full upgrade
Use a full upgrade when the release also needs broad Meilisearch work or non-code service operations.
deploy.cmd --full-upgrade
bash deploy.sh --full-upgrade
Full-upgrade mode:
- keeps the normal deploy steps
- forces a full Meilisearch import for all searchable models unless you explicitly pass
--skip-meilisearch - allows optional remote upgrade hooks for service-level work
Example with service hooks:
bash deploy.sh --full-upgrade \
--upgrade-pre-hook='sudo systemctl stop reverb' \
--upgrade-post-hook='sudo systemctl restart reverb meilisearch'
You can also provide those hooks through environment variables instead of CLI flags:
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \
bash deploy.sh --full-upgrade
Deploy options
deploy.cmd --skip-build
deploy.cmd --skip-migrate
deploy.cmd --no-maintenance
deploy.cmd --mode=full-upgrade
deploy.cmd --keep-releases=8
deploy.cmd --release-id=release-2026-04-25
deploy.cmd --build-number=100
deploy.cmd --no-rsync-progress
bash deploy.sh --skip-build
bash deploy.sh --skip-migrate
bash deploy.sh --no-maintenance
bash deploy.sh --mode=full-upgrade
bash deploy.sh --keep-releases=8
bash deploy.sh --release-id=release-2026-04-25
bash deploy.sh --build-number=100
bash deploy.sh --no-rsync-progress
Environment overrides:
REMOTE_SERVER=[email protected] REMOTE_FOLDER=/var/www/app bash deploy.sh
REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 bash deploy.sh
You can also override the local build command explicitly:
LOCAL_BUILD_COMMAND='npm run build' bash deploy.sh
LOCAL_BUILD_COMMAND='pnpm build' bash deploy.sh
Upgrade hooks can also be supplied via environment variables:
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash deploy.sh --full-upgrade
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash deploy.sh --full-upgrade
Rollback and release history
List retained releases on production:
bash scripts/rollback-production.sh --list
Switch production to the previous retained release:
bash scripts/rollback-production.sh --previous
Switch production to a specific retained release:
bash scripts/rollback-production.sh --release-id=20260425-132455-a1b2c3d
Preview a release switch without changing the server:
bash scripts/rollback-production.sh --previous --dry-run
Operational notes:
- Rollback now means switching the active server release, not re-syncing files from local.
- Each retained release already contains its own code and vendor tree, so rollback is primarily a symlink switch plus cache refresh and
queue:restart. - Rollback does not reverse database migrations. If a release includes incompatible schema changes, handle the database separately.
- Release retention defaults to 5 releases and can be changed with
--keep-releasesorRELEASE_RETENTION. - Reldeploy.sh --with-db-from=local
When run interactively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data.
For non-interactive use, pass both confirmations explicitly:
```bash
bash deploy.sh --with-db-from=local \
[email protected] \
--confirm-db-sync-phrase='replace production db from local'
Legacy compatibility still exists for:
bash deployinteractively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data.
For non-interactive use, pass both confirmations explicitly:
```bash
bash sync.sh --with-db-from=local \
--confirm-db-sync-target=[email protected] \
--confirm-db-sync-phrase='replace production db from local'
Legacy compatibility still exists for:
bash sync.sh --with-db --force-db-sync
But the safer --with-db-from=local flow should be preferred.
The database sync script will:
- read local DB credentials from the local
.env - create a local
mysqldumpexport - upload the dump to the production server
- create a backup of the current production database under
storage/app/deploy-backups - import the local dump into the production database
- run
php artisan migrate --forceunless--skip-migrateis passed
If you run the deploy from WSL while your local MySQL server is running on Windows with DB_HOST=127.0.0.1 or localhost, the DB sync script will automatically use Windows mysqldump.exe so it can still reach the local database.
You can override the dump command explicitly if needed:
LOCAL_MYSQLDUMP_COMMAND='mysqldump --host=10.0.0.5 --port=3306 --user=app dbname' bash scripts/push-db-to-prod.sh --force
Safety notes
- Normal deployments should use
deploy.cmdorbash deploy.shwithout--with-db. - Use
--full-upgradeonly when the release also includes Meilisearch-wide refreshes or remote service changes. - Use database replacement only for first-time bootstrap, staging, or an intentional full production reset.
- Use
bash scripts/rollback-production.sh --previousfor a fast server-side release switch when the last deploy needs to be reverted. - Route caching now runs through
php artisan optimizein deploy automation; if that starts failing again, fix the route definitions instead of dropping route caching from deploy. - On Windows, prefer
deploy.cmdso the process always enters WSL before rsync/ssh/php. It defaults to the localUbuntuWSL distro. The production server itself runs Debian 13; that is separate from the local WSL choice. Override withDEPLOY_WSL_DISTROonly if needed. If WSL has no usable~/.sshkeys (common when the distro runs as root and keys live inC:\Users\<you>\.ssh), the script copies those Windows identities into a 0600 temp dir and uses them for BatchMode SSH. Override the source withWINDOWS_SSH_DIR. - SSH still authenticates as
REMOTE_SERVER(for exampleklevze@host), but remote rsync/composer/artisan/release work runs asREMOTE_APP_USER(defaultskinbase) viasudo -n -u skinbase. This keeps release files and runtime dirs owned by the app user and avoids prune blockers likeowner=skinbase mode=2700 .config. SetREMOTE_APP_USER=-only to disable that and run as the SSH login user. - Remote app-user shells start in
/tmp(not the SSH user's home). The stableREMOTE_FOLDERsymlink is left alone once it already points at.../current; onlycurrentis rewritten each deploy. That avoidsPermission deniedon root-owned parents like/opt/www/virtual. - The deploy script refuses missing Vite/SSR build artifacts by default (
REQUIRE_BUILD_MANIFEST=1), verifies SSH non-interactively first, and takes a local deploy lock so two overlapping deploys do not race. - Inertia SSR restart is owned exclusively by Supervisor program
skinbase-ssr(deploy/supervisor/skinbase-ssr.conf). The remote application phase runs asskinbase, while the restart is issued separately over the SSH deployment session as the privileged login account withsudo -n /usr/bin/supervisorctl. The deploy fails if privileged Supervisor access or the configured program is unavailable, and verifies that the program reachesRUNNING; it never starts or stops SSR through Artisan.
Nginx upstream error pages
Laravel now owns the Nova-style HTML error pages for normal application responses, but true upstream failures such as 502 Bad Gateway and 504 Gateway Timeout still have to be handled by nginx because PHP/FPM is unavailable in that state.
The repo includes a ready-to-include snippet at deploy/nginx/upstream-error-pages.conf and the matching static page at public/errors/upstream-gateway.html.
To enable it on production:
- Include the snippet inside the Skinbase
server {}block. - Add
fastcgi_intercept_errors on;to every FastCGI location that should use the static fallback. - Keep the static file available in the live public path so nginx can serve it without Laravel.
On the current skinbase.org vhost, the required FastCGI locations are:
location ^~ /api/uploads/location = /index.php
This intentionally intercepts only 502 and 504, so Laravel remains responsible for normal 404, 419, 429, 500, and 503 rendering when the application is actually running.