A minimal, opinionated deployment tool for Composer based PHP projects, inspired by Deployer and Capistrano.
- Zero-downtime deployments with atomic releases
- Release management - keeps last N releases with easy rollback
- Shared files/directories - persistent data between releases
- Template variables from composer.json
- Pure Go implementation - single binary, no dependencies
- .gitignore support - respects your gitignore patterns
- SSH-based deployment with key authentication
- Colored output - clear, beautiful deployment progress
- TYPO3 optimized - sensible defaults for TYPO3 projects
brew tap ochorocho/shippy https://github.com/ochorocho/shippy
brew trust ochorocho/shippy
brew install shippyTo upgrade later:
brew upgrade shippygit clone https://github.com/ochorocho/shippy.git
cd shippy
go build -o shippy
sudo mv shippy /usr/local/bin/go install github.com/ochorocho/shippy@latest- Initialize configuration in your TYPO3 project root:
shippy initThis will create a .shippy.yaml file with sensible TYPO3 defaults and read your project name from composer.json.
- Edit configuration with your server details:
vim .shippy.yamlUpdate at minimum:
hostname- your server's domain or IPremote_user- SSH usernamessh_key- path to your SSH private key
- Validate configuration:
shippy config validate- Deploy to production:
shippy deploy productionDeploy from a pipeline. Both examples assume a .shippy.yaml in your repository and an SSH key for the target server provided as a secret/variable (see SSH Authentication).
Install and run shippy with the setup-shippy action:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ochorocho/shippy-action@v0.0.1
with:
args: deploy productionUse the prebuilt Docker image, which ships shippy on PATH:
# .gitlab-ci.yml
deploy:
image: ochorocho/shippy:latest
rules:
- if: $CI_COMMIT_BRANCH == "main"
script:
- shippy deploy productionhosts:
<hostname>:
# SSH connection
hostname: <server domain or IP>
port: <SSH port, default: 22>
remote_user: <SSH username>
ssh_key: <path to SSH private key>
ssh_options: <map of SSH options, see below>
# Deployment
deploy_path: <absolute path on server>
rsync_src: <local source directory>
keep_releases: <number of releases to keep, default: 5>
# File management
shared: <list of shared paths>
exclude: <additional exclude patterns>
include: <patterns to include despite .gitignore>
commands:
- name: <command description>
run: <command to execute>Exclude Patterns:
Shippy automatically respects .gitignore patterns including nested .gitignore files in subdirectories. You can add additional exclude patterns in your configuration:
hosts:
production:
hostname: example.com
remote_user: deploy
deploy_path: /var/www/myproject
rsync_src: ./
# Additional exclude patterns (beyond .gitignore)
exclude:
- "*.log" # Exclude all .log files
- ".env.example" # Exclude specific file
- "tests/" # Exclude entire directory
- "*.md" # Exclude all markdown files
- ".ddev/" # Exclude DDEV configuration
- "node_modules/" # Exclude node modules (if not in .gitignore)Include Patterns:
Use include to explicitly include files that are excluded by .gitignore:
hosts:
production:
hostname: example.com
remote_user: deploy
deploy_path: /var/www/myproject
rsync_src: ./
# Force include files despite .gitignore
include:
- "public/.htaccess" # Include .htaccess files
- "vendor/" # Include vendor directory (if gitignored)
- ".env.production" # Include specific environment filePattern Syntax:
- Patterns use gitignore-style syntax
*matches any characters except/**matches any characters including/- Trailing
/means directory only - No leading
/means pattern matches at any depth - Leading
/means pattern matches from project root
Examples:
exclude:
- "*.log" # All .log files at any depth
- "/build/" # build/ directory at root only
- "temp/" # temp/ directory at any depth
- "**/*.test.js" # All .test.js files anywhere
- ".DS_Store" # macOS metadata files
- "Thumbs.db" # Windows metadata filesSSH Key Detection:
The ssh_key field is optional. If not specified, Shippy will automatically try to find your SSH key in these locations (in order):
~/.ssh/id_ed25519~/.ssh/id_rsa~/.ssh/id_ecdsa
Explicit SSH Key:
hosts:
production:
hostname: example.com
remote_user: deploy
ssh_key: ~/.ssh/id_ed25519 # Optional: specify SSH private keyImportant: Always specify the private key (e.g., id_ed25519), not the public key (e.g., id_ed25519.pub).
You can configure SSH connection behavior using the ssh_options field. These options correspond to SSH configuration options (see man ssh_config):
hosts:
production:
hostname: example.com
port: 2222 # Custom SSH port (default: 22)
remote_user: deploy
# ssh_key is optional - will auto-detect from ~/.ssh/
# Advanced SSH options
ssh_options:
ConnectTimeout: "30" # Connection timeout (default: 30 seconds)
ServerAliveInterval: "60" # Send keepalive every 60 seconds
ServerAliveCountMax: "3" # Disconnect after 3 failed keepalives
Compression: "yes" # Enable SSH compression
StrictHostKeyChecking: "accept-new" # Host key verification mode
UserKnownHostsFile: "~/.ssh/known_hosts" # Known hosts file pathSpecifies the timeout for establishing an SSH connection. Supports multiple formats:
- Integer (seconds):
ConnectTimeout: "30"orConnectTimeout: 30 - Duration string:
ConnectTimeout: "30s",ConnectTimeout: "5m",ConnectTimeout: "1h"
Default: 30 seconds
Examples:
ssh_options:
ConnectTimeout: "10" # 10 seconds
ConnectTimeout: "30s" # 30 seconds
ConnectTimeout: "2m" # 2 minutesKeep SSH connections alive during long-running operations (deployments, database migrations, etc.) by sending periodic keepalive messages.
- ServerAliveInterval: Interval between keepalive messages. Supports same formats as ConnectTimeout.
- ServerAliveCountMax: Number of keepalive messages to send without response before disconnecting (default: 3)
Examples:
ssh_options:
ServerAliveInterval: "60" # Send keepalive every 60 seconds
ServerAliveCountMax: "3" # Disconnect after 3 failed attempts
# Or with duration format:
ServerAliveInterval: "1m" # Send keepalive every minuteUse case: For long-running deployments or commands, set ServerAliveInterval to prevent SSH timeouts:
ssh_options:
ServerAliveInterval: "30" # Keepalive every 30 seconds
ServerAliveCountMax: "5" # Allow up to 5 failed attempts (2.5 min grace)Enable SSH compression to reduce bandwidth usage. Particularly useful for large file transfers over slow connections.
- Values:
"yes","true","no","false"
Example:
ssh_options:
Compression: "yes" # Enable compressionNote: Go's SSH library handles compression negotiation with the server. If the server doesn't support compression, it will be automatically disabled.
Controls host key verification:
"yes"- Strict checking, reject unknown hosts (most secure)"accept-new"- Accept new hosts, verify known hosts (recommended default)"no"- Disable all verification (insecure, not recommended for production)
Example:
ssh_options:
StrictHostKeyChecking: "accept-new" # Accept first connection, verify thereafter
UserKnownHostsFile: "~/.ssh/known_hosts"Path to the known_hosts file for host key verification. Supports tilde expansion (~).
Default: ~/.ssh/known_hosts
Example:
ssh_options:
UserKnownHostsFile: "~/.ssh/my_known_hosts"hosts:
production:
hostname: example.com
port: 22
remote_user: deploy
ssh_key: ~/.ssh/id_ed25519
deploy_path: /var/www/myproject
ssh_options:
# Connection and timeout settings
ConnectTimeout: "30" # 30 second connection timeout
ServerAliveInterval: "60" # Keepalive every 60 seconds
ServerAliveCountMax: "3" # Disconnect after 3 failures
# Performance
Compression: "yes" # Enable compression
# Security
StrictHostKeyChecking: "accept-new"
UserKnownHostsFile: "~/.ssh/known_hosts"Note: The port field is a top-level configuration option for convenience. For other SSH options, use the ssh_options map.
Use {{key.path}} syntax to reference values from composer.json:
hosts:
production:
deploy_path: /var/www/{{name}} # Uses composer.json "name" fieldAccess nested values:
deploy_path: /var/www/{{extra.typo3/cms.web-dir}}Provide a fallback value with | (used when the key is not found in composer.json):
commands:
- name: Clear cache
run: ./{{config.bin-dir|vendor/bin}}/typo3 cache:flushUse ${VAR} syntax to reference environment variables:
hosts:
production:
hostname: ${DEPLOY_HOST}
remote_user: ${DEPLOY_USER}Provide a fallback value with | (used when the variable is not set):
hosts:
production:
deploy_path: ${DEPLOY_PATH|/var/www/html}If an environment variable is not set and no fallback is provided, deployment will fail with an error.
Files and directories in the shared: list are symlinked from the shared/ directory to each release:
shared:
- .env # Shared file
- var/log/ # Shared directory (note trailing slash)
- public/fileadmin/
- public/uploads/Shippy creates the following structure on the server (following Deployer/Capistrano conventions):
/var/www/myproject/
├── current -> releases/20240109120000 # Symlink to latest release
├── releases/
│ ├── 20240109120000/ # Current release
│ ├── 20240109110000/ # Previous release
│ └── 20240109100000/ # Older release
└── shared/
├── .env # Shared files
├── var/
│ ├── log/
│ └── session/
└── public/
├── fileadmin/
└── uploads/
Create a new configuration file with TYPO3 defaults:
shippy initOptions:
--forceor-f- Overwrite existing configuration file
This command:
- Checks for
composer.jsonin current directory - Reads project name from composer.json
- Generates
.shippy.yamlwith sensible TYPO3 defaults - Protects against accidental overwrites (use
--forceto override)
Deploy to a target host:
shippy deploy <hostname>Example:
shippy deploy staging
shippy deploy productionRollback to a previous release:
shippy rollback <hostname>Options:
--listor-l- List available releases and exit--releaseor-r- Switch to a specific release by name--offsetor-n- Relative offset from current release (negative = older, positive = newer)
Examples:
shippy rollback production # Interactive release selection
shippy rollback production -l # List available releases
shippy rollback production -n -1 # One version back
shippy rollback production -n +1 # One version forward (e.g., after accidental rollback)
shippy rollback production -n -2 # Two versions back
shippy rollback production -r 20260109120000 # Specific release by nameWhen run without flags, shows an interactive list of available releases with deployment date/time, git commit hash, and git tag. The current release is marked and cannot be selected.
Create a ZIP archive containing a database dump and selected files from the remote shared/ directory:
shippy backup <hostname>The output file is named backup-<hostname>-<timestamp>.zip and is written to the configured output: directory (default: current working directory).
Configuration in .shippy.yaml:
backup:
output: ./backups # Local directory for ZIPs (default: cwd)
# Files to download from the remote shared/ directory (paths are relative to shared/)
files:
- .env
- public/fileadmin/
- public/uploads/
database:
# How database credentials are obtained:
# auto - try `typo3 configuration:show` (TYPO3 v14+), then standard .env,
# then TYPO3 .env keys, then TYPO3 settings.php (default)
# dotenv - read DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD, ... from .env
# typo3 - try `typo3 configuration:show` (TYPO3 v14+), then TYPO3-specific
# .env keys or settings.php
# manual - use the explicit driver/host/port/name/user/password fields below
credentials: auto
# Required only when credentials: manual
# driver: mysql # mysql | postgresql | sqlite
# host: 127.0.0.1
# port: 3306
# name: my_database
# user: db_user
# password: ${DB_PASSWORD} # environment-variable substitution is supported
# Exclude tables from the dump (glob patterns)
exclude_tables:
- "cache_*"
- "cf_*"
- "sys_log"
- "be_sessions"
# DBMS-specific options
options:
single_transaction: "true" # MySQL: consistent dump without table locks
# charset: "utf8mb4" # MySQL
# schema: "public" # PostgreSQLWith credentials: auto or typo3, Shippy first runs ./vendor/bin/typo3 configuration:show DB/Connections/Default (available in TYPO3 v14+) to read the authoritative active database configuration. If the command is unavailable — older TYPO3, a non-bootstrappable app, or a non-TYPO3 project — it falls back to parsing .env keys and config/system/settings.php / legacy typo3conf/LocalConfiguration.php.
Per-host overrides are supported by adding a backup: block inside a hosts.<name>: entry — useful when staging and production need different exclude tables or output directories.
Upload any file (typically a backup ZIP) to the GitLab Generic Packages registry of the project's git origin:
shippy gitlab:upload <file>The GitLab host and project path are auto-detected from the origin remote URL — no YAML config required. Authentication resolves in this order:
--token <token>flagGITLAB_TOKENenvironment variableCI_JOB_TOKENenvironment variable (set automatically inside GitLab CI jobs)
Options:
--token/-t— GitLab token--package-name— package name (default: project name from the git remote)--package-version— package version (default: timestamp, e.g.,20260501T102420)
Typical local chain — back up production, then upload the archive:
shippy backup production
shippy gitlab:upload backups/backup-production-20260501T102420.zip --token "$GITLAB_TOKEN"Run as a scheduled GitLab CI pipeline (uses CI_JOB_TOKEN automatically):
# .gitlab-ci.yml
nightly_backup:
stage: backup
image: ghcr.io/ochorocho/shippy:latest
rules:
- if: $CI_PIPELINE_SOURCE == "schedule"
script:
- shippy backup production
- shippy gitlab:upload backups/backup-production-*.zipUploaded archives appear in Project → Deploy → Package Registry, grouped by <package-name>/<package-version>.
Check if your configuration is valid:
shippy config validateThis command:
- Validates YAML syntax
- Checks required fields
- Tests composer.json template variables
- Shows processed configuration
When you run shippy deploy <host>, the following steps occur:
- Scan files - Walks source directory, respects .gitignore and exclude patterns
- Connect to server - Establishes SSH connection
- Create release - Creates new timestamped release directory (e.g.,
releases/20260109203841) - Sync files - Transfers files to the new release directory
- Create symlinks - Links shared files/directories from
shared/to the release - Execute commands - Runs commands in the new release directory (e.g., cache flush, migrations)
- Activate release - Atomically updates
currentsymlink to new release (site goes live) - Cleanup - Removes old releases, keeps last N
Important: Commands execute in the new release directory before it goes live. This ensures all preparation (cache warming, migrations, etc.) completes successfully before the atomic switchover. The site only becomes live when the current symlink is updated in step 7.
hosts:
production:
hostname: www.example.com
remote_user: deploy
deploy_path: /var/www/{{name}}
rsync_src: ./
ssh_key: ~/.ssh/id_rsa
shared:
- .env
- var/log/
- var/session/
- public/fileadmin/
- public/uploads/
commands:
- name: Clear TYPO3 cache
run: ./vendor/bin/typo3 cache:flush
- name: Run extension setup
run: ./vendor/bin/typo3 extension:setuphosts:
staging:
hostname: staging.example.com
remote_user: deploy
deploy_path: /var/www/{{name}}/staging
rsync_src: ./
ssh_key: ~/.ssh/id_rsa
keep_releases: 3
# Additional excludes beyond .gitignore
exclude:
- .git/
- node_modules/
- .env.local
- Tests/
# Force include despite .gitignore
include:
- public/.htaccess
# Shared paths
shared:
- .env
- var/log/
- var/session/
- public/fileadmin/
- public/uploads/
production:
hostname: www.example.com
remote_user: deploy
deploy_path: /var/www/{{name}}/production
rsync_src: ./
ssh_key: ~/.ssh/id_rsa_production
keep_releases: 10
shared:
- .env
- var/log/
- var/session/
- public/fileadmin/
- public/uploads/
commands:
- name: Clear TYPO3 cache
run: ./{{config.bin-dir|vendor/bin}}/typo3 cache:flush
- name: Run extension setup
run: ./{{config.bin-dir|vendor/bin}}/typo3 extension:setup
- name: Database migrations
run: ./{{config.bin-dir|vendor/bin}}/typo3 upgrade:run
- name: Warmup caches
run: ./{{config.bin-dir|vendor/bin}}/typo3 cache:warmup
rollback_commands:
- name: Flush caches
run: ./{{config.bin-dir|vendor/bin}}/typo3 cache:flush
- name: Warmup caches
run: ./{{config.bin-dir|vendor/bin}}/typo3 cache:warmupShippy automatically excludes these patterns (in addition to .gitignore):
.git/.gitignore.shippy.yaml.shippy.yaml.examplenode_modules/.env.local.env.*.localvar/cache/var/log/var/transient/.DS_StoreThumbs.db
- Go 1.20 or higher (for building)
- SSH access to target server
- SSH key authentication configured
shippy/
├── cmd/
│ ├── root.go # Root CLI command
│ ├── config.go # Config validation command
│ └── deploy.go # Deploy command
├── internal/
│ ├── config/
│ │ ├── config.go # Configuration parser
│ │ └── template.go # Template variable processor
│ ├── composer/
│ │ └── parser.go # Composer.json parser
│ ├── rsync/
│ │ ├── sync.go # File scanner with gitignore
│ │ └── transfer.go # File transfer over SSH
│ ├── ssh/
│ │ ├── client.go # SSH client
│ │ └── executor.go # Command executor
│ └── deploy/
│ ├── deployer.go # Main deployment orchestrator
│ └── release.go # Release management
├── Formula/
│ └── shippy.rb # Homebrew formula (prebuilt binary, per arch)
├── scripts/
│ └── update-formula.sh # Bumps formula version + per-arch sha256 for a tag
├── main.go
├── go.mod
└── README.md
Maintainers: after tagging a release, run
make brew-formulato bumpFormula/shippy.rbto the latest tag (the Release workflow does this automatically on tagged builds).
go 1.24: go build -o shippy
Test instance
cd tests/
docker compose upcd tests/typo3/
composer installDeploy
../../shippy deploy productionTest SSH Connection
ssh -i tests/ssh_keys/shippy_key root@127.0.0.1 -p 2424Contributions are welcome! Please feel free to submit a Pull Request.