Skip to content
Published on

Deploying TYPO3 and Pimcore with Shippy

Authors

Shippy is a command-line deployment tool for Composer-based PHP projects, written in Go and distributed as a single static binary. It deploys over plain SSH, needs nothing installed on the target server beyond an SSH daemon, and produces zero-downtime, atomic releases using the same releases/ + current symlink + shared/ layout popularized by Capistrano and Deployer. Its own tagline puts it plainly: "Inspired by Deployer and Capistrano — without the runtime." This post is part one of a series; part two covers shippy backup, and part three compares it against Deployer and Capistrano directly.

This post walks through deploying TYPO3 and Pimcore with it, using real configuration from two live deployments — TYPO3 v14 and Pimcore 2026.1 — including the parts that don't show up in a quick-start guide: what breaks on a genuinely empty server, and why.

What Shippy actually does

Every shippy deploy <host> run goes through the same eight steps:

  1. Scan files — applies the deny-by-default allowlist (include:/exclude:)
  2. Connect to server — SSH
  3. Create release — a new timestamped directory, e.g. releases/20260812120000
  4. Sync files — transfers the scanned files into it
  5. Create symlinks — links shared/ paths into the release
  6. Execute commands — cache flush, migrations, warmup, all inside the new release
  7. Activate release — atomically repoints the current symlink; the site goes live
  8. Cleanup — removes old releases beyond keep_releases

The important detail is step 7. Steps 3–6 happen in a directory nobody is serving yet, so a failure anywhere in scanning, syncing or running commands simply never reaches production — the broken release is created on disk and left un-activated. The current symlink swap itself is atomic at the filesystem level, so there is no moment where the document root points at a half-written release. That's the entire zero-downtime mechanism; nothing more exotic than one ln -sfn.

Installing it

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

Or via Go, or a Docker image (ghcr.io/ochorocho/shippy), or a prebuilt binary from the releases page — see the installation guide for all four. There is no PHP, Ruby or Node runtime requirement on whichever machine runs shippy deploy, whether that's your laptop or a CI runner — it's a single Go binary.

A real .shippy.yaml for TYPO3

shippy init in a TYPO3 project root writes a config with TYPO3-aware defaults already filled in. Here is a trimmed version of what a working TYPO3 v14 deployment actually looks like — file selection, shared paths, and the default TYPO3 command set:

shared:
  - .env
  - var/log/
  - var/session/
  - public/fileadmin/
  - public/uploads/

include:
  - public/ # Web root: index.php, typo3/, installed extensions' Resources/Public
  - vendor/ # Composer dependencies — the server does not run "composer install" by default
  - config/ # config/sites/*/config.yaml + config/system/*.php
  - composer.json
  - composer.lock

exclude:
  - '*.log'
  - public/typo3temp/

hosts:
  production:
    hostname: t3.example.com
    remote_user: deploy
    deploy_path: /opt/apps/{{name}} # {{name}} comes from composer.json
    ssh_key: ~/.ssh/deploy_key

commands:
  - name: Composer install
    run: composer install --no-dev --optimize-autoloader

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

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

  - name: Clear TYPO3 cache
    run: ./vendor/bin/typo3 cache:flush

  - name: Warmup caches
    run: ./vendor/bin/typo3 cache:warmup

Two things worth calling out explicitly, because both are easy to get wrong the first time:

include: is mandatory, not optional. As of Shippy v0.1.0, file selection is deny-by-default: nothing ships unless it's listed under include:. Without one, shippy deploy scans zero files and warns you rather than failing silently. This is a breaking change from earlier versions, which shipped everything by default and relied on exclude: (and .gitignore) to keep things off the server — if you have a .shippy.yaml written against an older Shippy build, add an include: list before upgrading. .gitignore is never consulted for deployment either way; vendor/ being gitignored doesn't stop it from needing to be in include:.

Command order matters on a fresh database. extension:setup and upgrade:run have to run before cache:flush — a brand-new TYPO3 install has no cache tables yet, so flushing before the schema exists fails outright. This only bites once, on the one server that started from an empty database — see Bootstrap manually, automate the rest below for why that first deploy is worth treating as a separate step entirely.

A real .shippy.yaml for Pimcore

Shippy isn't TYPO3-exclusive — it deploys any Composer-based PHP project the same way. Here's the equivalent for a Pimcore 2026.1 project on PHP 8.5:

shared:
  - .env
  - .env.local
  - var/log/
  - var/assets/
  - var/tmp/
  - public/var/

include:
  - public/
  - vendor/
  - config/
  - bin/
  - composer.json
  - composer.lock

exclude:
  - '*.log'
  - '.docker/'
  - 'docker-compose.yaml'

hosts:
  production:
    hostname: pimcore.example.com
    remote_user: deploy
    deploy_path: /opt/apps/{{name}}
    ssh_key: ~/.ssh/deploy_key

commands:
  - name: Composer install
    run: composer install --no-dev --optimize-autoloader

  - name: Database migrations
    run: bin/console doctrine:migrations:migrate --no-interaction

  - name: Rebuild class definitions
    run: bin/console pimcore:deployment:classes-rebuild --create-classes --force --no-interaction

  - name: Install assets
    run: bin/console assets:install public --symlink --relative

  - name: Clear cache
    run: bin/console cache:clear

  - name: Warmup cache
    run: bin/console cache:warmup

The shape is identical to the TYPO3 config — an allowlist, a set of shared paths, a command list — with the specifics swapped for Pimcore's own console commands. That's the actual value of a deny-by-default, host-agnostic tool: the deployment mechanics don't change between frameworks, only the commands: block does.

Pimcore's first-ever install on an empty database is considerably more involved than the routine deploy above — a product license key, a custom install profile Pimcore ships no default implementation for, and at least one confirmed upstream migration bug needing a manual schema fix. None of that is a Shippy concern, and even less of it belongs in commands: than TYPO3's ordering issue — see the next section.

Bootstrap manually, automate the rest

Both gotchas above share one root cause: they're one-time environment setup, not routine deployment, and .shippy.yaml's commands: list runs identically on every deploy. Shippy's own docs are explicit that this is by design — it "does not manage your web server, PHP-FPM pools, database schema (beyond whatever your own commands do), or DNS." Trying to make a single command list handle both "this is the very first deploy to an empty database" and "this is the two-hundredth routine deploy" is what produces ordering bugs and installer workarounds leaking into a config file that's supposed to be declarative.

The straightforward fix: do the first-time install by hand, directly on the server, before .shippy.yaml or shippy deploy enter the picture at all.

  • TYPO3: run the schema setup (extension:setup, upgrade:run) and create the admin user once, manually. From then on, cache tables already exist, so commands: order in .shippy.yaml stops mattering — cache:flush running before or after extension:setup makes no difference once schema is already there.
  • Pimcore: register the product license, write and run the one-off install profile, apply the manual schema fix for the upstream migration bug — all once, by hand. None of these are things you'd want an automated pipeline attempting unattended anyway: a license registration step needs a human with license.pimcore.com access, and the install profile is deliberately temporary (added to config/bundles.php for the install, then removed again for security).

After that manual bootstrap, shippy deploy handles every deploy going forward, and the commands: blocks shown above stay exactly as simple as they look — no ordering caveats, no installer edge cases, because none of that code path runs again. The one-time cost is a manual install on a new environment; the payoff is a deploy config that only ever has to think about routine deploys.

Permissions on shared hosts

One gotcha that isn't specific to either framework: if the deploy user and the PHP-FPM/web-server user are different accounts sharing a parent directory (common on a shared or multi-tenant server), files can land without group access for PHP-FPM, which reads as a clean deploy followed by confusing 404s or permission-denied errors.

The right place to fix this is server provisioning, not the deploy config: a default ACL on deploy_path's parent directory (setfacl -Rm d:group:www-data:rwx /opt/apps, run once by whoever provisions the server) should be inherited by every file any tool later creates underneath it — permanently, without .shippy.yaml needing to know permissions are even a concern. Try that first.

In practice that inheritance doesn't always survive intact through Shippy's rsync-based file sync, so you may still see the problem even with a correctly provisioned parent directory. If it recurs, the deploy user re-applying the ACL to its own files as the last deploy command is a reasonable workaround — and, importantly, does not need sudo. POSIX ACLs can be set by whoever owns the files, for any group, without root and without being a member of that group yourself; the deploy user already owns every file it just synced:

commands:
  # ... all file-creating steps first (composer install, assets:install, cache:warmup) ...

  - name: Fix permissions for PHP-FPM
    run: setfacl -R -m group:www-data:rwx .

Treat sudo here as a signal something else is wrong, not a fix to reach for. If this genuinely fails without it in your setup, the underlying cause is worth tracking down — a deploy user that doesn't fully own its own release directory, most likely — rather than granting an automated pipeline passwordless root just to route around it. A deploy commands: list running as root on every single release is a meaningfully larger blast radius for any bug or compromise in that pipeline than the permissions problem it would be working around.

Previewing before you touch the server

--dry-run resolves the allowlist and prints the file count without opening an SSH connection:

shippy deploy production --dry-run

Run this before every first deploy to a new host, and after any change to include:/exclude:. shippy config validate catches YAML and template-variable errors even earlier, before a dry-run is worth attempting.

Rolling back

Because old releases stay on disk (keep_releases, default 5), undoing a deploy is the same symlink swap in reverse:

shippy rollback production -n -1        # one release back
shippy rollback production -l           # list releases with date, git commit, git tag

Any rollback_commands you've configured — typically cache flush and warmup — run as part of it.

What's next

Part two covers shippy backup: automated database dumps and shared-file archives, wiring them into a scheduled GitLab CI pipeline, and restoring one into DDEV for local development against real production data. Part three is a direct comparison against Deployer and Capistrano — config format, runtime requirements, and what each one gets you that the others don't.