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.

Maintainers

Package info

bitbucket.org/esportscz/deploy

Issues

pkg:composer/esportscz/deploy

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

1.4.2 2026-03-27 20:08 UTC

This package is auto-updated.

Last update: 2026-03-27 20:10:10 UTC


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
  • curl extension
  • git on the server

Installation

composer require esportscz/deploy

How it works

  1. Bitbucket pushes to deploy/prod → sends a webhook to https://your-server/public/_webhook.php
  2. WebhookHandler validates the request (IP whitelist, HMAC signature) and spawns a background process
  3. The background process runs DeployRunner: activates maintenance page → git fetch + git reset --hard → runs configured steps → deactivates maintenance page
  4. 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
ParameterTypeDefaultDescription
$noDevbooltruePass --no-dev to Composer
$classmapAuthoritativebooltruePass --classmap-authoritative

CacheClearStep

Recursively deletes the contents of the given directories (the directories themselves are preserved).

new CacheClearStep(['temp/cache', 'temp/proxies'])
ParameterTypeDescription
$directoriesstring[]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')
ParameterTypeDescription
$crontabFilestringPath 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')
ParameterTypeDefaultDescription
$commandstringShell command to execute
$namestring$commandDisplay 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
ParameterTypeDefaultDescription
$consoleScriptstring'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
ParameterTypeDefaultDescription
$pdo\PDOActive PDO connection
$migrationsDirstring'migrations'Directory with .sql files, relative to projectRoot
$tableNamestring'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 MigrationStepInterfacehasPendingMigrations() 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
ParameterTypeDefaultDescription
$hoststring'127.0.0.1'Redis host
$portint6379Redis port
$databaseint0Redis 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.*')
ParameterTypeDefaultDescription
$hoststring'127.0.0.1'RabbitMQ host
$portint15672Management API port
$usernamestring'guest'Management user
$passwordstring'guest'Management password
$vhoststring'/'Virtual host

Fluent methods:

MethodDescription
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:

KeyTypeDescription
x-dead-letter-exchangestringExchange to route rejected messages to
x-dead-letter-routing-keystringRouting key for dead-lettered messages
x-message-ttlintMessage TTL in milliseconds
x-max-lengthintMaximum 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
ParameterTypeDefaultDescription
$programsstring[][]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
ParameterTypeDefaultDescription
$keepCountint30Number 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)
ParameterTypeDefaultDescription
$urlstringURL to check
$timeoutSecondsint10Request 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.

OptionDefaultDescription
--dirmigrations/Directory where the new file will be placed
--helpShow 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
OptionDefaultDescription
--dirmigrations/Migrations directory
--dsnPDO DSN string
--userDatabase username
--passDatabase password
--initSafe mode: removes DROP, adds IF NOT EXISTS
--sshSSH tunnel (user@host) for remote DB
--ssh-port22SSH port
--ssh-keyPath to SSH private key
--helpShow 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
OptionDefaultDescription
--dirmigrations/Migrations directory
--tabledeploy_migrationsTracking table name
--dsnPDO DSN string
--userDatabase username
--passDatabase password
--dry-runList pending migrations without executing
--sshSSH tunnel (user@host) for remote DB
--ssh-port22SSH port
--ssh-keyPath to SSH private key
--helpShow 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:

VariableDescription
DISCORD_TOKENBot token. Create a bot at discord.com/developers and copy its token
DISCORD_CHANNELRight-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:

  1. setWebhookSecret() called directly on the config
  2. WEBHOOK_SECRET environment variable
  3. (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:

MethodReturnsDescription
getBranch()stringe.g. deploy/prod
getEnvironment()stringe.g. prod
getProjectRoot()stringAbsolute path to the project
getCommitHash()stringShort commit hash
getCommitMessage()stringFirst line of the commit message
getStartedAt()DateTimeImmutableDeploy start time
getConfig()DeployConfigFull config object

Examples

Ready-to-use webhook files are in the examples/ directory:

FileDescription
01-minimal.phpBare minimum — Composer install + cache clear, no notifications
02-production.phpFull production setup — Discord, HMAC, crontab
03-multi-environment.phpprod / dev / test with different steps per environment
04-custom-step.phpBuilt-in steps + how to write a custom one
05-custom-ip-validator.phpStatic IP whitelist, disabling IP check, GitHub-style setup
06-database-migrations.phpDoctrineMigrationsStep 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_ROOT points to public/ or www/, not the project root
  • [ ] The web server can execute php and git
  • [ ] Bitbucket webhook is configured: URL https://your-server/public/_webhook.php, trigger Push
  • [ ] If using HMAC: the same secret is set in Bitbucket and in WEBHOOK_SECRET env var
  • [ ] If using Discord: DISCORD_TOKEN and DISCORD_CHANNEL env vars are set
  • [ ] log/ and temp/ directories are writable by the web server user