esportscz / deploy
Composer library for automated deployments triggered by Bitbucket webhooks. Install the package, create a single PHP file, and configure the deploy pipeline in code.
Requires
- php: ^7.2 || ^8.0
- ext-pdo: *
Requires (Dev)
- nette/tester: ^2.4
- orisai/coding-standard: ^3.0
- phpstan/phpstan: ^1.0
README
Composer library for automated deployments triggered by Bitbucket webhooks.
Instead of copying bash scripts into every project, you install this package, create a single PHP file, and configure the deploy pipeline in code.
// public/_webhook.php
$config = (new DeployConfig())
->setProjectRoot(__DIR__ . '/..')
->setLogDirectory('log/deploy')
->setWebhookLogFile('log/webhook.log')
->setNotifier(new DiscordNotifier(getenv('DISCORD_TOKEN'), getenv('DISCORD_CHANNEL')))
->addStep(new ComposerInstallStep())
->addStep(new CacheClearStep(['temp/cache', 'temp/proxies']))
->addStep(new CrontabInstallStep('bin/deploy/prod.crontab'), ['prod'])
->addStep(new LogRotateStep());
(new WebhookHandler($config))->handle();
Requirements
- PHP 7.2 or higher
curlextensiongiton the server
Installation
composer require esportscz/deploy
How it works
- Bitbucket pushes to
deploy/prod→ sends a webhook tohttps://your-server/public/_webhook.php WebhookHandlervalidates the request (IP whitelist, HMAC signature) and spawns a background process- The background process runs
DeployRunner: activates maintenance page →git fetch+git reset --hard→ runs configured steps → deactivates maintenance page - Each step reports success or failure; on failure the deploy stops and Discord receives the log file
For manual deploys via SSH, the same file serves as a CLI entrypoint:
php public/_webhook.php deploy/prod
Configuration
All configuration is done through the DeployConfig fluent builder.
setProjectRoot(string $path): self
Absolute path to the project root (the directory containing composer.json).
->setProjectRoot(__DIR__ . '/..')
setBranchEnvironments(array $map): self
Maps branch names to environment names. Defaults to ['deploy/prod' => 'prod', 'deploy/dev' => 'dev'].
->setBranchEnvironments([
'deploy/prod' => 'prod',
'deploy/dev' => 'dev',
'deploy/test' => 'test',
])
addStep(StepInterface $step, ?array $environments = null): self
Registers a deploy step. Without the second argument the step runs for all environments. Pass an array of environment names to restrict it.
->addStep(new ComposerInstallStep()) // all environments
->addStep(new CrontabInstallStep('bin/deploy/prod.crontab'), ['prod']) // prod only
->addStep(new CrontabInstallStep('bin/deploy/test.crontab'), ['test']) // test only
setNotifier(NotifierInterface $notifier): self
Sets the notifier used to report deploy progress. Defaults to NullNotifier (silent).
->setNotifier(new DiscordNotifier(getenv('DISCORD_TOKEN'), getenv('DISCORD_CHANNEL')))
setWebhookSecret(string $secret): self
HMAC secret for verifying the X-Hub-Signature header sent by Bitbucket. If not called, the package falls back to the WEBHOOK_SECRET environment variable. If neither is set, HMAC validation is skipped and only IP whitelisting is used.
Configure the same value in Bitbucket under Repository settings → Webhooks → Secret.
->setWebhookSecret(getenv('WEBHOOK_SECRET'))
setIpValidator(IpValidatorInterface $validator): self
Overrides the default BitbucketIpValidator. Use this when accepting webhooks from GitHub, a self-hosted Git server, or a CI system with a known IP.
->setIpValidator(new StaticIpValidator(['203.0.113.42']))
setMaintenancePage(string $path): self
Path to a custom maintenance HTML file. The package ships a default one at resources/maintenance.html. The file is copied into the web root directory at the start of the deploy and removed on success.
->setMaintenancePage(__DIR__ . '/../resources/my-maintenance.html')
setWebRoot(string $relativePath): self
Web-accessible directory relative to projectRoot where the maintenance page is placed during deploy. Defaults to 'www'.
->setWebRoot('public') // maintenance page goes to {projectRoot}/public/maintenance.html
setMaintenanceFileName(string $fileName): self
Filename of the maintenance page placed in the web root. Defaults to 'maintenance.html'.
->setMaintenanceFileName('_maintenance.php')
// combined with setWebRoot('public') → {projectRoot}/public/_maintenance.php
setLogDirectory(string $relativePath): self
Directory for timestamped deploy log files, relative to projectRoot. Each deploy creates a new file like 2026-03-03_14-25-00.log. Defaults to log/deploy.
->setLogDirectory('log/deploy')
Each log file contains git output, the list of changed files, and per-step results:
[14:25:00] Git output:
From git.example.com:org/repo
* branch deploy/prod -> FETCH_HEAD
HEAD is now at abc1234 Fix payment timeout
[14:25:00] Changed files (3):
src/Service/PaymentService.php
config/services.yaml
templates/checkout.html.twig
[14:25:01] composer install: OK (12 packages)
[14:25:04] Cache clear: Cleared 3 directories
setWebhookLogFile(string $relativePath): self
Path to the webhook log file, relative to projectRoot. When set, every incoming webhook request is logged (accepted and rejected) with details about which security policy was applied. The log uses append mode, so entries accumulate over time.
->setWebhookLogFile('log/webhook.log')
Log format:
[2026-03-03 14:23:45] IP=1.2.3.4 STATUS=rejected POLICIES=[vendor_check,ip_whitelist(BitbucketIpValidator),hmac] FAILED=ip_whitelist REASON="IP not allowed: 1.2.3.4"
[2026-03-03 14:25:00] IP=34.199.54.113 STATUS=accepted POLICIES=[vendor_check,ip_whitelist(BitbucketIpValidator),hmac] BRANCH=deploy/prod
setWebhookLogMaxLines(int $maxLines): self
Maximum number of lines kept in the webhook log file. When the file exceeds this limit, the oldest lines are automatically removed after each write, so the file never grows beyond the configured size. Defaults to 2000. Set to 0 to disable rotation (unlimited growth).
->setWebhookLogMaxLines(2000) // default — keep last 2000 lines
->setWebhookLogMaxLines(500) // smaller log
->setWebhookLogMaxLines(0) // disable rotation
setLogFile(string $relativePath): self
Deprecated — use
setLogDirectory()instead.
Path to the deploy log file, relative to projectRoot. This method is kept for backward compatibility but has no effect when setLogDirectory() is used.
->setLogFile('log/deploy.log')
setLockFile(string $relativePath): self
Path to the lock file used to prevent concurrent deploys, relative to projectRoot. Defaults to temp/deploy.lock. A second deploy triggered while the first is running will wait (blocking) until the first finishes.
->setLockFile('temp/deploy.lock')
disableVendorExposureCheck(): self
Disables the automatic check that rejects requests when vendor/ is web-accessible. By default, WebhookHandler compares DOCUMENT_ROOT with the vendor/ path and returns HTTP 500 if the directory appears exposed. Some server configurations (reverse proxy, symlinks, non-standard DOCUMENT_ROOT) can trigger false positives — use this method to suppress the check.
->disableVendorExposureCheck()
setStepTimeout(int $seconds): self
Maximum execution time per step in seconds. Defaults to 300.
->setStepTimeout(600)
Built-in steps
ComposerInstallStep
Downloads composer.phar if missing and runs composer install.
new ComposerInstallStep() // --no-dev --classmap-authoritative (default)
new ComposerInstallStep(false) // with dev dependencies
new ComposerInstallStep(true, false) // --no-dev, no classmap
| Parameter | Type | Default | Description |
|---|---|---|---|
$noDev | bool | true | Pass --no-dev to Composer |
$classmapAuthoritative | bool | true | Pass --classmap-authoritative |
CacheClearStep
Recursively deletes the contents of the given directories (the directories themselves are preserved).
new CacheClearStep(['temp/cache', 'temp/proxies'])
| Parameter | Type | Description |
|---|---|---|
$directories | string[] | Paths relative to projectRoot |
CrontabInstallStep
Installs or updates a named section in the system crontab. The section is identified by the absolute path of the .crontab file, so multiple projects on the same server never overwrite each other's entries.
new CrontabInstallStep('bin/deploy/prod.crontab')
| Parameter | Type | Description |
|---|---|---|
$crontabFile | string | Path to the .crontab file, relative to projectRoot |
The file uses the standard crontab format. The step wraps it in ### BEGIN ... ### END markers and merges it into the live crontab on each deploy, so changes to the file are automatically applied.
ShellCommandStep
Runs an arbitrary shell command in the project root. Use this as an escape hatch for commands that don't have a dedicated step.
new ShellCommandStep('php bin/console --no-interaction orm:generate-proxies')
new ShellCommandStep('php bin/console migrations:migrate', 'Database migrations')
| Parameter | Type | Default | Description |
|---|---|---|---|
$command | string | — | Shell command to execute |
$name | string | $command | Display name used in notifications and logs |
DoctrineMigrationsStep
Runs php bin/console migrations:migrate for projects using Doctrine Migrations. Automatically finds the latest committed migration via git ls-files migrations | sort | tail -n1, migrates to that version, then removes any uncommitted files from the migrations/ directory.
new DoctrineMigrationsStep() // uses bin/console (default)
new DoctrineMigrationsStep('bin/console') // explicit path
| Parameter | Type | Default | Description |
|---|---|---|---|
$consoleScript | string | 'bin/console' | Path to the Symfony console, relative to projectRoot |
Implements MigrationStepInterface, which adds hasPendingMigrations(DeployContext $context): bool. This method runs migrations:status and returns true when uncommitted migrations are detected — useful for conditional logic in custom steps.
SqlMigrationStep
Applies plain .sql files for projects without Doctrine. Files are executed in filename order (alphanumeric sort) and tracked in a database table so each file runs only once.
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
new SqlMigrationStep($pdo) // migrations/, deploy_migrations table
new SqlMigrationStep($pdo, 'db/migrations', 'schema_versions') // custom dir + table
| Parameter | Type | Default | Description |
|---|---|---|---|
$pdo | \PDO | — | Active PDO connection |
$migrationsDir | string | 'migrations' | Directory with .sql files, relative to projectRoot |
$tableName | string | 'deploy_migrations' | Tracking table name (auto-created on first run) |
Tracking table (auto-created, compatible with MySQL, PostgreSQL, and SQLite):
CREATE TABLE IF NOT EXISTS deploy_migrations (
filename VARCHAR(255) NOT NULL,
applied_at VARCHAR(20) NOT NULL,
PRIMARY KEY (filename)
)
Implements MigrationStepInterface — hasPendingMigrations() returns true when unapplied .sql files are found.
RedisCacheStep
Flushes a Redis database using redis-cli. Requires redis-cli to be installed on the server.
new RedisCacheStep() // 127.0.0.1:6379, database 0
new RedisCacheStep('127.0.0.1', 6379, 1) // specific database
| Parameter | Type | Default | Description |
|---|---|---|---|
$host | string | '127.0.0.1' | Redis host |
$port | int | 6379 | Redis port |
$database | int | 0 | Redis database index |
RabbitMqStep
Declares exchanges, queues and bindings via the RabbitMQ Management HTTP API (port 15672). All operations are idempotent — safe to run on every deploy. Requires the management plugin to be enabled (rabbitmq-plugins enable rabbitmq_management).
(new RabbitMqStep('127.0.0.1', 15672, 'guest', 'guest'))
->declareExchange('app', 'topic')
->declareQueue('emails', [
'arguments' => [
'x-dead-letter-exchange' => 'dlx',
'x-dead-letter-routing-key' => 'failed',
'x-message-ttl' => 86400000,
],
])
->bindQueue('emails', 'app', 'email.*')
| Parameter | Type | Default | Description |
|---|---|---|---|
$host | string | '127.0.0.1' | RabbitMQ host |
$port | int | 15672 | Management API port |
$username | string | 'guest' | Management user |
$password | string | 'guest' | Management password |
$vhost | string | '/' | Virtual host |
Fluent methods:
| Method | Description |
|---|---|
declareExchange(string $name, string $type = 'direct', array $options = []) | Declares an exchange. Types: direct, fanout, topic, headers |
declareQueue(string $name, array $options = []) | Declares a queue. Common option keys: durable, auto_delete, arguments |
bindQueue(string $queue, string $exchange, string $routingKey = '') | Binds a queue to an exchange |
Common queue arguments:
| Key | Type | Description |
|---|---|---|
x-dead-letter-exchange | string | Exchange to route rejected messages to |
x-dead-letter-routing-key | string | Routing key for dead-lettered messages |
x-message-ttl | int | Message TTL in milliseconds |
x-max-length | int | Maximum number of messages in the queue |
Exchanges must be declared before queues that reference them (e.g. as a dead-letter exchange). The step preserves declaration order, so call declareExchange before declareQueue where there is a dependency.
SupervisorStep
Runs supervisorctl reread && supervisorctl update to apply config changes, then optionally restarts the specified programs.
new SupervisorStep() // reread + update only
new SupervisorStep(['worker', 'scheduler']) // restart specific programs
new SupervisorStep(['workers:*']) // restart a group
new SupervisorStep(['all']) // restart everything
| Parameter | Type | Default | Description |
|---|---|---|---|
$programs | string[] | [] | Programs to restart. Accepts names, groups (group:*), and all |
LogRotateStep
Rotates old deploy log files in the log directory. Keeps the most recent files and deletes the rest.
new LogRotateStep() // keep last 30 (default)
new LogRotateStep(10) // keep last 10
| Parameter | Type | Default | Description |
|---|---|---|---|
$keepCount | int | 30 | Number of recent logs to keep |
Add this step at the end of your step chain so old logs are cleaned up after every deploy.
HealthCheckStep
Sends a HEAD request to a URL after the deploy and fails if the response is not HTTP 200. Useful for catching application boot errors before the deploy is declared successful.
new HealthCheckStep('https://example.com/healthz')
new HealthCheckStep('https://example.com/healthz', 30)
| Parameter | Type | Default | Description |
|---|---|---|---|
$url | string | — | URL to check |
$timeoutSeconds | int | 10 | Request timeout |
CLI tools
After installation, three helper scripts are available at vendor/bin/:
deploy-migrate-create
Interactively creates a new timestamped SQL migration file.
vendor/bin/deploy-migrate-create # creates in migrations/ (default)
vendor/bin/deploy-migrate-create --dir=db/migrations # custom directory
Example session:
Migration description (recommended, press Enter to skip): Add user preferences table
Created: migrations/20231201120000_add_user_preferences_table.sql
The description is normalised to snake_case and appended after the timestamp. If you skip the description you are asked to confirm before the file is created.
| Option | Default | Description |
|---|---|---|
--dir | migrations/ | Directory where the new file will be placed |
--help | — | Show usage information |
deploy-migrate-dump
Dumps the current database schema as a baseline .sql file. Any existing .sql files in the target directory are moved to a timestamped archive-YYYYMMDDHHMMSS/ subdirectory first.
vendor/bin/deploy-migrate-dump \
--dsn="mysql:host=localhost;dbname=myapp" \
--user=root \
--pass=secret
# or rely on environment variables / .env file:
vendor/bin/deploy-migrate-dump
| Option | Default | Description |
|---|---|---|
--dir | migrations/ | Migrations directory |
--dsn | — | PDO DSN string |
--user | — | Database username |
--pass | — | Database password |
--init | — | Safe mode: removes DROP, adds IF NOT EXISTS |
--ssh | — | SSH tunnel (user@host) for remote DB |
--ssh-port | 22 | SSH port |
--ssh-key | — | Path to SSH private key |
--help | — | Show usage information |
Safe init mode for adding schema to an existing database without dropping tables:
vendor/bin/deploy-migrate-dump --init \
--dsn="mysql:host=localhost;dbname=myapp" \
--user=root \
--pass=secret
This removes DROP TABLE statements and adds IF NOT EXISTS to CREATE TABLE (and CREATE SEQUENCE / CREATE INDEX for PostgreSQL). The output file is named 00000000000000_init.sql and existing migrations are not archived.
SSH tunnel for remote databases behind a bastion host or private network:
vendor/bin/deploy-migrate-dump \
--dsn="mysql:host=10.0.0.5;dbname=myapp" \
--user=root --pass=secret \
--ssh=deploy@bastion.example.com
# With custom SSH port and key
vendor/bin/deploy-migrate-dump \
--dsn="pgsql:host=db-server;dbname=myapp" \
--user=postgres \
--ssh=deploy@bastion.example.com \
--ssh-port=2222 \
--ssh-key=~/.ssh/deploy_rsa
The --ssh option opens an SSH tunnel, rewrites the DSN to 127.0.0.1:<local-port>, and closes the tunnel automatically when done.
Supported drivers: mysql (uses mysqldump --no-data) and pgsql (uses pg_dump --schema-only).
Credential resolution order: CLI arguments → DB_DSN / DB_USER / DB_PASS environment variables → .env file in the current directory.
deploy-migrate-run
Runs pending SQL migrations against a database. Uses the same migration format and tracking table as SqlMigrationStep, so migrations applied during deploy are not re-applied locally and vice versa.
vendor/bin/deploy-migrate-run \
--dsn="mysql:host=localhost;dbname=myapp" \
--user=root \
--pass=secret
# or rely on environment variables / .env file:
vendor/bin/deploy-migrate-run
Example output:
Pending migrations: 2
Applying 20240310143022_create_users_table.sql ... OK
Applying 20240315091500_add_email_index.sql ... OK
Done. 2 migration(s) applied.
Dry-run mode lists pending migrations without executing them:
vendor/bin/deploy-migrate-run --dry-run
Pending migrations: 2
20240310143022_create_users_table.sql
20240315091500_add_email_index.sql
| Option | Default | Description |
|---|---|---|
--dir | migrations/ | Migrations directory |
--table | deploy_migrations | Tracking table name |
--dsn | — | PDO DSN string |
--user | — | Database username |
--pass | — | Database password |
--dry-run | — | List pending migrations without executing |
--ssh | — | SSH tunnel (user@host) for remote DB |
--ssh-port | 22 | SSH port |
--ssh-key | — | Path to SSH private key |
--help | — | Show usage information |
Credential resolution order: CLI arguments → DB_DSN / DB_USER / DB_PASS environment variables → .env file in the current directory.
Notifiers
DiscordNotifier
Sends deploy progress to a Discord channel via the Bot API. The first message is created with POST, subsequent updates edit the same message with PATCH — so the channel stays clean. On failure, the full log file is uploaded as an attachment.
new DiscordNotifier(
getenv('DISCORD_TOKEN'), // Bot token from Discord Developer Portal
getenv('DISCORD_CHANNEL') // Channel ID (not name)
)
Required environment variables:
| Variable | Description |
|---|---|
DISCORD_TOKEN | Bot token. Create a bot at discord.com/developers and copy its token |
DISCORD_CHANNEL | Right-click a channel in Discord → Copy Channel ID (requires Developer Mode) |
NullNotifier
Default notifier — does nothing. No configuration needed.
Security
Vendor directory exposure check
WebhookHandler automatically checks whether the vendor/ directory is accessible from the web on every HTTP request. If it is (i.e. DOCUMENT_ROOT points to the project root instead of to public/ or www/), the handler returns HTTP 500 with an explanatory message. No configuration required.
Correct server setup:
/var/www/project/
├── public/ ← DOCUMENT_ROOT points here
│ └── _webhook.php
├── src/
├── vendor/ ← not accessible from the web
└── composer.json
IP validation
By default, incoming requests are validated against the official Atlassian IP ranges (Bitbucket). The list is cached for 1 hour in the system temp directory. Invalid IPs receive HTTP 404.
To accept webhooks from other sources, implement IpValidatorInterface:
use eSportsCZ\Deploy\Contract\IpValidatorInterface;
class StaticIpValidator implements IpValidatorInterface
{
public function isValid(string $ip): bool
{
return in_array($ip, ['203.0.113.42'], true);
}
}
To disable IP validation entirely (e.g. for cloud CI runners with dynamic IPs), use a permissive validator and always pair it with an HMAC secret:
class AnyIpValidator implements IpValidatorInterface
{
public function isValid(string $ip): bool { return true; }
}
$config->setIpValidator(new AnyIpValidator())
->setWebhookSecret(getenv('WEBHOOK_SECRET'));
HMAC signature
When a webhook secret is configured, the X-Hub-Signature: sha256=<hmac> header is verified before the payload is processed. Requests with a missing or invalid signature receive HTTP 404.
Set the secret in Bitbucket: Repository settings → Webhooks → edit → Secret.
The secret is resolved in this order:
setWebhookSecret()called directly on the configWEBHOOK_SECRETenvironment variable- (not set — HMAC validation is skipped)
Concurrent deploy lock
DeployRunner acquires an exclusive flock() lock before starting a deploy. If a deploy is already in progress, the next one waits (blocking) until the first finishes. The lock is released automatically by the OS on process exit, so a crash cannot cause a permanent dead-lock.
Custom steps
Implement StepInterface to add any project-specific logic:
use eSportsCZ\Deploy\Contract\StepInterface;
use eSportsCZ\Deploy\DeployContext;
use eSportsCZ\Deploy\DeployResult;
class ReloadPhpFpmStep implements StepInterface
{
public function getName(): string
{
return 'Reload PHP-FPM';
}
public function run(DeployContext $context): DeployResult
{
exec('sudo systemctl reload php8.2-fpm 2>&1', $output, $exitCode);
return $exitCode === 0
? DeployResult::success()
: DeployResult::failure(implode("\n", $output));
}
}
DeployContext provides access to:
| Method | Returns | Description |
|---|---|---|
getBranch() | string | e.g. deploy/prod |
getEnvironment() | string | e.g. prod |
getProjectRoot() | string | Absolute path to the project |
getCommitHash() | string | Short commit hash |
getCommitMessage() | string | First line of the commit message |
getStartedAt() | DateTimeImmutable | Deploy start time |
getConfig() | DeployConfig | Full config object |
Examples
Ready-to-use webhook files are in the examples/ directory:
| File | Description |
|---|---|
01-minimal.php | Bare minimum — Composer install + cache clear, no notifications |
02-production.php | Full production setup — Discord, HMAC, crontab |
03-multi-environment.php | prod / dev / test with different steps per environment |
04-custom-step.php | Built-in steps + how to write a custom one |
05-custom-ip-validator.php | Static IP whitelist, disabling IP check, GitHub-style setup |
06-database-migrations.php | DoctrineMigrationsStep and SqlMigrationStep side by side |
Copy the closest example to public/_webhook.php in your project and adjust as needed.
Manual deploy via SSH
The webhook file doubles as a CLI entrypoint. This is useful for the first deploy, for recovery, or for testing configuration changes.
php public/_webhook.php deploy/prod
The branch argument must be one of the configured branch environments (e.g. deploy/prod, deploy/dev). The deploy runs synchronously with output printed to stdout.
Server setup checklist
Before the first deploy, run the preparation script on the server:
bash vendor/esportscz/deploy/src/bin/deploy/_prepare-server.sh
Make sure the following are in place:
- [ ]
DOCUMENT_ROOTpoints topublic/orwww/, not the project root - [ ] The web server can execute
phpandgit - [ ] Bitbucket webhook is configured: URL
https://your-server/public/_webhook.php, triggerPush - [ ] If using HMAC: the same secret is set in Bitbucket and in
WEBHOOK_SECRETenv var - [ ] If using Discord:
DISCORD_TOKENandDISCORD_CHANNELenv vars are set - [ ]
log/andtemp/directories are writable by the web server user