Skip to content

Repository files navigation

Shippy

A minimal, opinionated deployment tool for Composer based PHP projects, inspired by Deployer and Capistrano.

Shippy

Features

  • Zero-downtime deployments with atomic releases
  • Release management - keeps last N releases with easy rollback
  • Deployment locking - prevents concurrent deployments to the same host
  • Shared files/directories - persistent data between releases
  • Template variables from composer.json
  • Pure Go implementation - single binary, no dependencies
  • Deny-by-default file selection - explicit allowlist, nothing ships unless you include it
  • SSH-based deployment with key authentication
  • Colored output - clear, beautiful deployment progress
  • TYPO3 optimized - sensible defaults for TYPO3 projects

Installation

Using Homebrew

brew tap ochorocho/shippy https://github.com/ochorocho/shippy
brew trust ochorocho/shippy
brew install shippy

To upgrade later:

brew upgrade shippy

From Source

git clone https://github.com/ochorocho/shippy.git
cd shippy
go build -o shippy
sudo mv shippy /usr/local/bin/

Using Go Install

go install github.com/ochorocho/shippy@latest

Quick Start

  1. Initialize configuration in your TYPO3 project root:
shippy init

This will create a .shippy.yaml file with sensible TYPO3 defaults and read your project name from composer.json.

  1. Edit configuration with your server details:
vim .shippy.yaml

Update at minimum:

  • hostname - your server's domain or IP
  • remote_user - SSH username
  • ssh_key - path to your SSH private key
  • include - the allowlist of paths to deploy (deny-by-default; adjust to your project layout)
  1. Validate configuration:
shippy config validate
  1. Deploy to production:
shippy deploy production

CI/CD

Deploy 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).

GitHub Actions

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 production

GitLab CI

Use 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 production

Configuration

Basic Structure

hosts:
  <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>
    include: <allowlist - paths to deploy (deny-by-default)>
    exclude: <carve-outs that win over includes>

commands:
  - name: <command description>
    run: <command to execute>

Commands

commands (and rollback_commands) run in the new release directory, in order, before the atomic switchover. Each entry needs a name and a run:

commands:
  - name: Install dependencies
    run: composer install --no-dev --optimize-autoloader

  - name: Database migrations
    run: ./vendor/bin/typo3 upgrade:run

Scoping a command to specific hosts:

By default a command runs for every host in hosts:. Use only / except (GitLab-CI style) to scope a single command instead of duplicating the whole command list per host:

commands:
  - name: Database migrations
    run: ./vendor/bin/typo3 upgrade:run
    # Only run this command for the listed host(s) (keys under `hosts:`).
    # Skipped everywhere else. Omit `only`/`except` entirely to run on every host.
    only:
      - production

  - name: Notify monitoring
    run: /usr/local/bin/notify-deploy
    # except is the inverse of only: runs everywhere except the listed hosts.
    except:
      - staging
  • only and except accept a list of host names (the keys under hosts:).
  • If a host matches both, except wins.
  • Commands skipped for a host are logged as skipped during deploy/rollback.
  • shippy config validate rejects only/except entries that reference a host name not defined under hosts:.
  • shippy config show <host> lists only the commands that actually apply to that host; shippy config validate annotates every command with its resolved scope.

Running commands inside a container (command_context):

If your PHP sources are mounted into a container, command_context runs every command inside a subcontext instead of directly on the remote host. When set, Shippy executes each command as:

<command_context> sh -c 'cd <release-dir> && <run>'

Shippy adds sh -c itself — give only the container-entry prefix, without a trailing shell (docker exec php85, not docker exec php85 bash). The cd happens inside the context, so the release directory must resolve to the same path there (the normal bind-mount case, e.g. /var/www:/var/www).

command_context can be set globally, per host, or per command — the most specific one wins:

# Global default for every host and command (optional; empty = run directly on the host)
command_context: docker exec -u www-data php85

hosts:
  production:
    hostname: example.com
    remote_user: deploy
    command_context: docker exec php84  # Overrides the global default for this host

commands:
  - name: Notify monitoring
    run: /usr/local/bin/notify-deploy
    # Per-command override. An empty string forces this command to run
    # directly on the host even when a global/per-host context is set.
    command_context: ""

Precedence: per-command > per-host (hosts.<name>.command_context) > global command_context.

File Selection: Deny-by-Default (Allowlist)

Shippy deploys files using an allowlist: by default nothing is deployed unless it is listed under include:. A project root usually contains far more that should not ship (.git/, node_modules/, dumps, IDE files, .env) than should — an allowlist is safer and less error-prone than trying to exclude every unwanted path.

Include = the allowlist. List exactly what should ship. A directory entry ships that directory and everything beneath it:

hosts:
  production:
    hostname: example.com
    remote_user: deploy
    deploy_path: /var/www/myproject
    rsync_src: ./

    include:
      - "public/"            # Web root (ships the whole subtree)
      - "vendor/"            # Composer dependencies
      - "config/"            # TYPO3 configuration
      - "composer.json"
      - "composer.lock"

Exclude = carve-outs. Excludes always win over includes, so use them to punch holes in an included directory:

    exclude:
      - "public/typo3temp/"  # Drop generated temp files under an included dir
      - "*.log"              # Never ship log files

Common junk — .git/, node_modules/, var/cache/, var/log/, .DS_Store, Thumbs.db and more (see Default Excludes) — is carved out automatically; you don't need to list it.

Notes:

  • include:/exclude: are not merged across hosts — a per-host list replaces the global one entirely. Give each host its own allowlist (or define one at the global level and omit it per host).
  • .gitignore is not consulted for deployment. You select what ships with include:; you don't rely on gitignore to deselect.
  • Escape hatch: include: ["*"] ships (almost) everything; the built-in junk list still protects .git/ etc.

Pattern Syntax:

  • Patterns use gitignore-style syntax
  • A single-segment pattern (vendor/, *.log) matches at any depth
  • A multi-segment pattern (public/index.php) is anchored to the project root
  • Trailing / means directory only
  • * matches within a path segment; ** matches across segments

Migrating from a previous version

Earlier versions shipped everything by default and used exclude: (and .gitignore) to remove unwanted files. Deny-by-default reverses this:

  • Add an include: allowlist to every host (or globally). Without it, shippy deploy scans 0 files and warns you.
  • Existing exclude: entries still work, now as carve-outs on top of your includes.
  • .gitignore no longer affects what is deployed — anything you relied on gitignore to exclude is already excluded by default; anything gitignored that you still need (e.g. vendor/) simply goes in include:.
  • Run shippy config validate and review shippy deploy's "Found N files to sync" count before your first real deploy.

SSH Authentication

SSH 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):

  1. ~/.ssh/id_ed25519
  2. ~/.ssh/id_rsa
  3. ~/.ssh/id_ecdsa

Explicit SSH Key:

hosts:
  production:
    hostname: example.com
    remote_user: deploy
    ssh_key: ~/.ssh/id_ed25519  # Optional: specify SSH private key

Important: Always specify the private key (e.g., id_ed25519), not the public key (e.g., id_ed25519.pub).

SSH Agent:

If ssh-agent is running (SSH_AUTH_SOCK is set) or, on Windows, Pageant is running, Shippy also offers every key the agent holds — in addition to, not instead of, ssh_key. The server tries every offered key during authentication, so both sources are tried automatically; nothing needs to be configured to enable this.

This matters most for passphrase-protected keys: Shippy cannot decrypt a passphrase-protected private key file itself, but if the same key is already loaded in your agent (ssh-add), it's used from there instead, and ssh_key can point at that same encrypted file without issue. With an agent running, ssh_key also becomes fully optional — no default key needs to exist on disk at all.

hosts:
  production:
    hostname: example.com
    remote_user: deploy
    # No ssh_key needed - authenticates entirely via ssh-agent.
    # Run `ssh-add ~/.ssh/id_ed25519` beforehand so the agent holds the key.

SSH Options

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 path

ConnectTimeout

Specifies the timeout for establishing an SSH connection. Supports multiple formats:

  • Integer (seconds): ConnectTimeout: "30" or ConnectTimeout: 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 minutes

ServerAliveInterval and ServerAliveCountMax

Keep 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 minute

Use 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)

Compression

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 compression

Note: Go's SSH library handles compression negotiation with the server. If the server doesn't support compression, it will be automatically disabled.

StrictHostKeyChecking

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"

UserKnownHostsFile

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"

Complete Example

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.

SSH Multiplexing

By default, Shippy opens a fresh SSH connection for each remote operation. Set ssh_multiplexing: true to reuse a single shared connection (SSH ControlMaster) for all operations against a host, which noticeably reduces overhead on high-latency links or deployments that run many commands:

hosts:
  production:
    hostname: example.com
    remote_user: deploy
    ssh_multiplexing: true   # Reuse one connection for all operations (default: false)

Template Variables

Use {{key.path}} syntax to reference values from composer.json:

hosts:
  production:
    deploy_path: /var/www/{{name}}  # Uses composer.json "name" field

Access 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:flush

Environment Variables

Use ${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.

Shared Files/Directories

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/

Deployment Locking

To prevent two deployments from running against the same host at once, Shippy writes a lock file to the remote deploy_path at the start of a deploy and removes it when finished. Locking is enabled by default with a 15-minute timeout, after which a stale lock (e.g. from a crashed deployment) is considered expired and automatically overridden.

# Global defaults (can be overridden per host)
lock_enabled: true    # Enable deployment locking (default: true)
lock_timeout: 15      # Minutes before a stale lock expires (default: 15)

hosts:
  production:
    hostname: example.com
    remote_user: deploy
    deploy_path: /var/www/myproject
    # lock_enabled: false   # Per-host override to disable locking

If a deployment fails and leaves a stale lock behind before the timeout elapses, clear it manually with shippy unlock.

Directory Structure

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/

Commands

Initialize

Create a new configuration file with TYPO3 defaults:

shippy init

Options:

  • --force or -f - Overwrite existing configuration file

This command:

  • Checks for composer.json in current directory
  • Reads project name from composer.json
  • Generates .shippy.yaml with sensible TYPO3 defaults
  • Protects against accidental overwrites (use --force to override)

Deploy

Deploy to a target host:

shippy deploy <hostname>

Options:

  • --dry-run - Preview which files and commands would be deployed without connecting to the host
  • --verbose or -v - Show detailed output for each file

Example:

shippy deploy staging
shippy deploy production
shippy deploy production --dry-run   # Preview files and commands, no connection

When run without a host argument, an interactive host selector is shown.

Rollback

Rollback to a previous release:

shippy rollback <hostname>

Options:

  • --list or -l - List available releases and exit
  • --release or -r - Switch to a specific release by name
  • --offset or -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 name

When 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.

Backup

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).

Options:

  • --output or -o - Output directory for the backup ZIP (overrides the configured output:)
  • --skip-database - Skip the database dump
  • --skip-shared - Skip the shared files
  • --verbose or -v - Show detailed output

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"           # PostgreSQL

With 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 to GitLab

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:

  1. --token <token> flag
  2. GITLAB_TOKEN environment variable
  3. CI_JOB_TOKEN environment 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-*.zip

Uploaded archives appear in Project → Deploy → Package Registry, grouped by <package-name>/<package-version>.

Validate Configuration

Check if your configuration is valid:

shippy config validate

This command:

  • Validates YAML syntax
  • Checks required fields
  • Tests composer.json template variables
  • Shows processed configuration

Show Configuration

Print the complete resolved configuration with all defaults applied and template variables replaced:

shippy config show              # Complete config with resolved templates
shippy config show production   # Effective config for a single host (globals + per-host overrides)
shippy config show --raw        # Raw config without resolving template variables

For a single host, the output lists only the commands that actually apply to that host (given each command's only/except filters); skipped commands are shown as comments.

Unlock

Force-remove a stale deployment lock from a host (see Deployment Locking):

shippy unlock <hostname>

Example:

shippy unlock                  # Interactive host selection
shippy unlock production

Use this only when a deployment failed and left a lock behind. Running it while a deployment is genuinely in progress may cause issues. If no active lock exists, the command reports that and exits without changes.

Environment

Print all environment variables available to Shippy. Useful for debugging configuration that uses ${ENV_VAR} substitution:

shippy env
shippy env | grep DEPLOY

Version

Print the version, git commit, build date, and Go version:

shippy version
shippy --version   # or -v

Global Options

--config <path> selects a different configuration file (default: .shippy.yaml). It is available on every command:

shippy --config .shippy.staging.yaml deploy staging

Deployment Process

When you run shippy deploy <host>, the following steps occur:

  1. Scan files - Walks source directory, applies the deny-by-default allowlist (include/exclude patterns)
  2. Connect to server - Establishes SSH connection
  3. Create release - Creates new timestamped release directory (e.g., releases/20260109203841)
  4. Sync files - Transfers files to the new release directory
  5. Create symlinks - Links shared files/directories from shared/ to the release
  6. Execute commands - Runs commands in the new release directory (e.g., cache flush, migrations)
  7. Activate release - Atomically updates current symlink to new release (site goes live)
  8. 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.

Example Configuration

Minimal TYPO3 Configuration

hosts:
  production:
    hostname: www.example.com
    remote_user: deploy
    deploy_path: /var/www/{{name}}
    rsync_src: ./
    ssh_key: ~/.ssh/id_rsa
    # Deny-by-default: list exactly what should ship
    include:
      - public/
      - vendor/
      - config/
      - composer.json
      - composer.lock
    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:setup

Complete TYPO3 Include List

Under deny-by-default you must explicitly list every path a Composer-based TYPO3 installation needs to run. The block below is the complete allowlist for a standard project, with each entry annotated. Copy it and delete the optional lines that don't apply to your project.

hosts:
  production:
    hostname: www.example.com
    remote_user: deploy
    deploy_path: /var/www/{{name}}
    rsync_src: ./

    include:
      # --- Required: a Composer TYPO3 install will not boot without these ---
      - public/          # Web root: index.php, typo3/, _assets/, installed extensions' Resources/Public
      - vendor/          # All Composer dependencies incl. typo3/cms-core and vendor/bin/typo3
                         # (the server does NOT run "composer install")
      - config/          # Site config (config/sites/*/config.yaml) + system config (config/system/*.php)
      - composer.json    # TYPO3 reads it for package metadata / extension autoloading
      - composer.lock    # Pins the installed set; used by post-deploy commands

      # --- Optional: uncomment the ones your project actually uses ---
      # - packages/      # Local site extensions kept in the repo (monorepo layout)
      # - .htaccess      # Root .htaccess, if you serve from the project root
      # - api/           # Additional entry points / sub-apps outside public/

    # Carve-outs: excludes always win over includes. Common junk (.git/,
    # node_modules/, var/cache/, var/log/, .DS_Store, ...) is already excluded
    # automatically, so you only need project-specific holes here.
    exclude:
      - public/typo3temp/   # Generated at runtime, never ship it

    # Runtime/persistent data — symlinked from shared/, never part of a release
    shared:
      - .env
      - var/log/
      - var/session/
      - public/fileadmin/
      - public/uploads/

commands:
  - name: Run extension setup
    run: ./vendor/bin/typo3 extension:setup

  - name: Run upgrade wizards
    run: ./vendor/bin/typo3 upgrade:run

  - name: Flush caches
    run: ./vendor/bin/typo3 cache:flush

Note: If your project uses a non-standard web directory (configured via the extra.typo3/cms.web-dir key in composer.json, e.g. web/ instead of public/), include that directory instead of public/. Run shippy deploy <host> --dry-run to preview exactly which files the allowlist resolves to before deploying.

Advanced Configuration

hosts:
  staging:
    hostname: staging.example.com
    remote_user: deploy
    deploy_path: /var/www/{{name}}/staging
    rsync_src: ./
    ssh_key: ~/.ssh/id_rsa
    keep_releases: 3

    # Allowlist: exactly what ships (deny-by-default)
    include:
      - public/
      - vendor/
      - config/
      - composer.json
      - composer.lock

    # Carve-outs (win over includes). Common junk is already excluded.
    exclude:
      - public/typo3temp/
      - Tests/

    # 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
    include:
      - public/
      - vendor/
      - config/
      - composer.json
      - composer.lock
    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:warmup

Default Excludes

These carve-out patterns are always applied and win over your include: allowlist, so common junk never ships even inside an included directory:

  • .git/
  • .gitignore
  • .shippy.yaml
  • .shippy.yaml.example
  • node_modules/
  • .env.local
  • .env.*.local
  • var/cache/
  • var/log/
  • var/transient/
  • .DS_Store
  • Thumbs.db

Requirements

  • Go 1.20 or higher (for building)
  • SSH access to target server
  • SSH key authentication configured

Project Structure

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      # Allowlist file scanner (deny-by-default)
│   │   └── 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-formula to bump Formula/shippy.rb to the latest tag (the Release workflow does this automatically on tagged builds).

Testing

go 1.24: go build -o shippy

Test instance

cd tests/
docker compose up
cd tests/typo3/
composer install

Deploy

../../shippy deploy production

Test SSH Connection

ssh -i tests/ssh_keys/shippy_key root@127.0.0.1 -p 2424

License

MIT

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

About

Opinionated deployment tool for PHP/Composer based projects

Resources

Stars

13 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages