Uruchom dep deploy ze standardowym recipe/laravel.php, a na produkcji wydarzą się trzy rzeczy, których nie chcę. Composer działa na serwerze, więc kod, który obsługuje ruch, nigdy nie był tym kodem, który przetestowało CI. artisan:migrate odpala się przy każdym deployu, niezależnie od tego, czy ktoś zatwierdził migrację w tym wydaniu. A jeśli nowy release po przełączeniu current zaczyna zwracać 500, Deployer melduje sukces i serwuje go dalej, dopóki człowiek nie wpisze dep rollback.
W moim starterze Laravela zostawiłem Deployerowi to, co robi dobrze (katalogi release'ów, pliki współdzielone, blokadę deployu, atomowe przełączenie, sprzątanie), a resztę wymieniłem. Ten post przechodzi przez ten deploy.php. Bloki PHP od sekcji „Host i ustawienia” czytane po kolei tworzą kompletną receptę. Cel: Deployer 8 (v8.0.5, stan na 2026-10-27), jedna VM z Nginx i PHP-FPM, a GitHub Actions jako jedyne miejsce, w którym jest klucz SSH.
Co naprawdę robi standardowa recepta
Zanim cokolwiek nadpiszesz, przeczytaj receptę. W Deployerze 8 recipe/laravel.php definiuje deploy tak:
task('deploy', [
'deploy:prepare',
'deploy:vendors',
'artisan:storage:link',
'artisan:optimize',
'artisan:migrate',
'deploy:publish',
'artisan:reload',
]);
deploy:prepare to grupa: deploy:info, deploy:setup, deploy:lock, deploy:release, deploy:update_code, deploy:env, deploy:shared, deploy:writable. deploy:publish to deploy:symlink, deploy:unlock, deploy:cleanup, deploy:success.
Ta struktura jest najcenniejsza. Każdy krok to nazwany task, a ponowna definicja taska o tej samej nazwie podmienia jego ciało. Dlatego zostawiam grupę, podmieniam deploy:update_code i deploy:vendors i piszę własną listę deploy.
Na serwerze Deployer układa katalogi tak:
/var/www/starter/
├── .dep/ # blokada, log release'ów, moje pliki stanu
├── current -> releases/42
├── releases/
│ ├── 38/ … 42/
└── shared/
├── .env
└── storage/
Host i ustawienia przychodzą ze środowiska
W repozytorium nie ma nic o serwerze docelowym. Workflow przekazuje host, użytkownika i ścieżkę w zmiennych środowiskowych, a recepta bez nich nie wystartuje.
<?php
declare(strict_types=1);
namespace Deployer;
require 'recipe/laravel.php';
/** @return non-empty-string */
function deploymentEnvironment(string $name): string
{
$value = getenv($name);
if ($value === false || $value === '') {
throw new \RuntimeException(sprintf('Brak wymaganej zmiennej środowiskowej %s.', $name));
}
return $value;
}
host(deploymentEnvironment('DEPLOY_ENVIRONMENT'))
->setHostname(deploymentEnvironment('DEPLOY_HOST'))
->setRemoteUser(deploymentEnvironment('DEPLOY_USER'))
->set('deploy_path', deploymentEnvironment('DEPLOY_PATH'));
set('application', 'starter');
set('keep_releases', 5);
set('shared_files', ['.env']);
set('shared_dirs', ['storage']);
set('writable_dirs', ['bootstrap/cache', 'storage']);
set('writable_mode', 'acl');
set('writable_use_sudo', false);
set('artifact_path', deploymentEnvironment('DEPLOY_ARTIFACT'));
set('artifact_sha256', deploymentEnvironment('DEPLOY_ARTIFACT_SHA256'));
.env leży w shared/, a deploy:shared linkuje go do każdego release'u. keep_releases mówi, ile katalogów zostawi deploy:cleanup, czyli też, jak daleko wstecz da się cofnąć.
Build w CI, na serwer leci tarball
Job budujący w GitHub Actions odpala testy, buduje assety Vite i bundle SSR, potem robi composer install --no-dev --classmap-authoritative i pakuje wynik: git archive plus vendor/, public/build i bootstrap/ssr. Obok tarballa zapisuje jego SHA-256 i mały manifest (commit, wersje PHP i Node, hashe lockfile'ów).
Na serwerze nie ma Gita, Composera ani Node'a. deploy:update_code zamienia się w upload:
task('deploy:update_code', function (): void {
$artifactPath = get('artifact_path');
if (! is_file($artifactPath)) {
throw new \RuntimeException(sprintf('Brak artefaktu release: %s.', $artifactPath));
}
upload($artifactPath, '{{release_path}}/release.tar.gz');
run('test "$(sha256sum {{release_path}}/release.tar.gz | cut -d " " -f 1)" = "{{artifact_sha256}}"');
run('tar -xzf {{release_path}}/release.tar.gz -C {{release_path}} --no-same-owner');
run('rm {{release_path}}/release.tar.gz');
run('printf "%s\n" "{{release_name}}" > {{release_path}}/RELEASE');
});
task('deploy:vendors', function (): void {
run('test -f {{release_path}}/vendor/autoload.php');
});
Suma kontrolna jest porównywana na serwerze, po uploadzie, więc ucięty transfer wywraca deploy, zanim cokolwiek zostanie rozpakowane. deploy:vendors niczego już nie instaluje, tylko sprawdza, czy artefakt przyniósł własny vendor/. Plik RELEASE czyta config/ops.php, dzięki czemu każda linia logu ma numer release'u.
Migracje tylko na wyraźne życzenie
Standardowy artisan:migrate odpala się zawsze. Ja chcę, żeby człowiek przy każdym deployu zdecydował, że to wydanie może ruszyć schemat:
task('deploy:migrate', function (): void {
if (getenv('DEPLOY_ALLOW_MIGRATIONS') !== 'true') {
writeln('<comment>Migracje pominięte: DEPLOY_ALLOW_MIGRATIONS=true jest wymagane.</comment>');
return;
}
run('cd {{release_path}} && php artisan migrate --force --no-interaction');
});
Flaga to input typu boolean w workflow, domyślnie false. Migracje lecą z nowego release'u przed przełączeniem, kiedy stary release dalej obsługuje ruch. To działa tylko wtedy, gdy migracja jest addytywna, czyli zgodnie z zasadą expand/contract z migracji bazy danych bez przestoju. Ta recepta nigdy nie cofa schematu. Zła migracja dostaje forward fix.
Sprawdź kandydata, zanim dostanie ruch
W tym momencie nowy release ma zbudowane cache (artisan:config:cache, route:cache, view:cache, event:cache), a current dalej wskazuje stary. To najtańszy moment na porażkę:
task('deploy:smoke', function (): void {
run('test -f {{release_path}}/public/build/manifest.json');
run('test -f {{release_path}}/bootstrap/ssr/app.js');
run('cd {{release_path}} && php artisan about --only=environment --no-interaction');
run('cd {{release_path}} && php artisan ops:readiness --no-interaction');
});
php artisan about bootuje framework z cache'owanym configiem release'u, więc zepsuty service provider albo plik konfiguracyjny wołający nieistniejącą klasę wyłoży się tutaj. ops:readiness to komenda startera: z CLI, na nowym kodzie, robi te same kontrole co endpoint /health/ready (baza, Redis, zapisywalny storage). Jeśli któraś linia zwróci kod różny od zera, Deployer się zatrzymuje, odpala deploy:failed, a użytkownicy tego release'u nie zobaczyli.
Zapamiętaj, który release był zdrowy
Wbudowany dep rollback bierze poprzedni katalog z logu release'ów. Przy automatycznym rollbacku chcę czegoś ściślejszego: release'u, który minutę temu obsługiwał ruch.
task('deploy:remember_healthy', function (): void {
if (test('[ -L {{current_path}} ]')) {
$healthy = run('readlink -f {{current_path}}');
set('healthy_release', $healthy);
run('mkdir -p {{deploy_path}}/.dep && printf "%s\n" '.escapeshellarg($healthy).' > {{deploy_path}}/.dep/healthy_release');
} else {
set('healthy_release', '');
}
});
Trafia to też do .dep/healthy_release, więc po przerwanym deployu operator widzi cel bez czytania logów CI. Przy pierwszym deployu nie ma current, więc nie ma do czego wracać.
Przełącz atomowo i niech Nginx za tym nadąży
deploy:symlink w Deployerze 8 robi mv -T {{deploy_path}}/release {{current_path}}, jeśli mv obsługuje --no-target-directory. Zmiana nazwy jest atomowa, więc żaden request nie trafi na brakujące current.
Haczykiem jest PHP-FPM. Jeśli Nginx przekazuje SCRIPT_FILENAME ze ścieżką zawierającą current, OPcache po przełączeniu dalej serwuje stare pliki. Dokumentacja Deployera zaleca rozwiązanie symlinka w Nginx zamiast przeładowywania FPM i tak robi vhost startera:
root /var/www/starter/current/public;
location = /index.php {
include fastcgi_params;
fastcgi_pass unix:/run/php/starter.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root/index.php;
fastcgi_param DOCUMENT_ROOT $realpath_root;
}
Każdy request jest przypinany do releases/42/public w chwili, gdy Nginx rozwiązuje ścieżkę, a requesty już trwające kończą się na releases/41.
Zrestartuj to, co trzyma kod w pamięci
Horizon, scheduler i serwer SSR Inertii ładują kod raz i go trzymają. Każdy działa jako unit systemd z WorkingDirectory=/var/www/starter/current i Restart=always, więc wystarczy je zatrzymać. systemd uruchomi je ponownie z nowego current:
task('deploy:restart_workers', function (): void {
run('cd {{current_path}} && php artisan horizon:terminate --no-interaction');
run('cd {{current_path}} && php artisan schedule:interrupt --no-interaction');
run('cd {{current_path}} && (php artisan inertia:stop-ssr --no-interaction || true)');
});
horizon:terminate pozwala workerom dokończyć bieżący job. Zatrzymanie SSR może się nie udać, bo na świeżym hoście procesu SSR jeszcze nie ma. Z tego samego powodu joby w kolejce muszą być zgodne między kolejnymi release'ami: job wrzucony przez release 41 może podjąć worker na release 42. Więcej o tym w kolejkach Laravela w głąb.
Jeden automatyczny rollback, nigdy dwa
Po przełączeniu recepta pyta prawdziwą stronę, czy jest gotowa. Request idzie do lokalnego Nginx, ale z publiczną nazwą hosta przypiętą przez curl --resolve, więc TLS i TrustHosts zachowują się tak jak dla użytkowników:
set('smoke_command', function (): string {
$host = getenv('DEPLOY_SMOKE_HOST');
if ($host === false || preg_match('/\A[a-z0-9.-]+\z/i', $host) !== 1) {
throw new \RuntimeException('DEPLOY_SMOKE_HOST musi być nazwą hosta.');
}
return sprintf(
'curl --fail --silent --show-error --max-time 5 --resolve %1$s:443:127.0.0.1 https://%1$s/health/ready > /dev/null',
$host,
);
});
function readinessPasses(): bool
{
for ($attempt = 1; $attempt <= 3; $attempt++) {
if (test(get('smoke_command'))) {
return true;
}
writeln(sprintf('<comment>Readiness: próba %d/3 nieudana.</comment>', $attempt));
if ($attempt < 3) {
run('sleep 5');
}
}
return false;
}
task('deploy:smoke:live', function (): void {
if (readinessPasses()) {
return;
}
$failedRelease = run('readlink -f {{current_path}}');
$healthy = (string) get('healthy_release');
if ($healthy === '' || $healthy === $failedRelease || ! test('[ -d '.escapeshellarg($healthy).' ]')) {
throw new \RuntimeException('Readiness po przełączeniu nie przeszła, a nie ma zdrowego release’u, do którego można wrócić.');
}
run('cd {{deploy_path}} && ln -sfn '.escapeshellarg($healthy).' current.rollback && mv -T current.rollback current');
run('printf "%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ) readiness failed" > '.escapeshellarg($failedRelease.'/BAD_RELEASE'));
invoke('deploy:restart_workers');
if (! readinessPasses()) {
run('printf "%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ) rollback target unhealthy" > {{deploy_path}}/.dep/deploy_blocked');
throw new \RuntimeException('Wydanie cofnięte, ale poprzednie również nie przechodzi readiness. Deploye zablokowane.');
}
throw new \RuntimeException('Wydanie cofnięte do '.basename($healthy).'.');
});
task('deploy:check_blocked', function (): void {
if (test('[ -f {{deploy_path}}/.dep/deploy_blocked ]')) {
throw new \RuntimeException('Deploye zablokowane po nieudanym rollbacku (.dep/deploy_blocked).');
}
});
Liczą się tu trzy szczegóły. Rollback tworzy tymczasowy symlink i przenosi go na current, czyli robi ten sam atomowy ruch co przełączenie w przód (wbudowany task rollback używa bezpośrednio ln -nfs). Zapisuje BAD_RELEASE w katalogu nieudanego release'u, a to jest dokładnie ten znacznik, który wbudowany dep rollback sprawdza, zanim wybierze kandydata. I dzieje się raz. Jeśli stary release też jest chory, problem leży raczej w bazie, Redisie albo dysku, a przeskakiwanie między dwoma release'ami tylko by to ukryło. Recepta zapisuje wtedy .dep/deploy_blocked i każdy kolejny deploy zatrzymuje się na pierwszym tasku, dopóki człowiek nie usunie tego pliku. W każdej gałęzi deploy kończy się błędem, więc run workflow jest czerwony i nikt nie weźmie rollbacku za sukces.
Pełny plik w starterze wysyła też linie logger -p user.crit, które łapie alerting. Tutaj je pominąłem, żeby przepływ był czytelny.
Lista tasków
Standardowa lista odpala artisan:optimize i artisan:migrate bezwarunkowo, więc definiuję deploy jawnie:
task('deploy', [
'deploy:check_blocked',
'deploy:prepare',
'deploy:vendors',
'artisan:storage:link',
'artisan:config:cache',
'artisan:route:cache',
'artisan:view:cache',
'artisan:event:cache',
'deploy:migrate',
'deploy:smoke',
'deploy:remember_healthy',
'deploy:symlink',
'deploy:restart_workers',
'deploy:smoke:live',
'deploy:unlock',
'deploy:cleanup',
'deploy:success',
]);
after('deploy:failed', 'deploy:unlock');
Wszystko powyżej deploy:symlink może się wywalić bez wiedzy użytkowników. Wszystko poniżej jest objęte rollbackiem. after('deploy:failed', 'deploy:unlock') zwalnia blokadę po porażce, więc kolejny run nie wymaga najpierw dep deploy:unlock.
Uruchamianie z GitHub Actions
Job deployujący pobiera artefakt zbudowany przez poprzedni job, sprawdza SHA-256 i to, czy commit z manifestu równa się GITHUB_SHA, instaluje Deployera z composer.json (deployer/deployer: ^8.0 w require-dev), zapisuje klucz SSH i przypięty known_hosts, a potem odpala:
export DEPLOY_ARTIFACT_SHA256="$(cut -d ' ' -f 1 .release/starter.tar.gz.sha256)"
vendor/bin/dep deploy "$DEPLOY_ENVIRONMENT" --file=deploy.php --revision="$GITHUB_SHA" --ansi
Workflow startuje tylko przez workflow_dispatch, z concurrency: deploy-${{ inputs.environment }} i cancel-in-progress: false, więc dwa deploye nigdy się nie nakładają, a trwająca migracja nigdy nie zostanie przerwana. Staging i produkcja to environments w GitHubie. Klucz SSH jest sekretem environmentu, więc istnieje tylko w tym jobie, a required reviewers na production to miejsce, w którym człowiek zatwierdza wydanie (sprawdź, czy twój plan GitHuba daje je dla prywatnych repozytoriów). Stronę GitHub Actions dla deployu na symlinkach bez Deployera opisałem we wdrażaniu bez przestojów z GitHub Actions.
Kiedy tego nie używać
- Masz kontenery. Na Kubernetesie albo Docker Swarm artefaktem jest obraz, a przełączenie i rollback robi orkiestrator. Deployer dublowałby jedno i drugie.
- Masz kilka serwerów webowych za load balancerem. Deployer obsłuży wiele hostów, ale przełączenie przestaje być atomowe między nimi. Health checki per host należą do load balancera.
- Robi to już platforma. Laravel Cloud, Forge z Envoyerem i podobne usługi dają katalogi release'ów i health checki bez recepty do utrzymania.
- Twoje migracje nie są addytywne. Wtedy żadne narzędzie nie da ci deployu bez przestoju. Zaplanuj okno serwisowe i powiedz to wprost.
Dla jednej VM z Nginx i PHP-FPM pełna recepta to około 230 linii PHP, a każdy krok jest nazwanym taskiem, który zobaczysz w dep tree deploy.
Jeśli chcesz mieć to postawione na swoich serwerach albo potrzebujesz drugiej pary oczu do pipeline'u deployu, który twój zespół już ma, zobacz moją ofertę.
Źródła
- Kod Deployera 8,
recipe/laravel.php,recipe/common.php,recipe/deploy/*.php, tag v8.0.5 (dostęp: 2026-10-27) - Deployer: Avoid PHP-FPM reloading (dostęp: 2026-10-27)
- Deployer: Laravel recipe (dostęp: 2026-10-27)
- Laravel 13: Horizon, deploying (dostęp: 2026-10-27)
- Laravel 13: Task scheduling, interrupting sub-minute tasks (dostęp: 2026-10-27)
- GitHub Actions: Using environments for deployment (dostęp: 2026-10-27)