Deploy a prepared PHP application to shared hosting as one authenticated ZIP release instead of thousands of individual FTP operations.
The action is designed for hosting environments with FTPS and web PHP but without SSH. It uploads one release archive, an optional environment file, a short standalone PHP bootstrap, and a release-specific HMAC secret. The bootstrap validates the complete request before extracting or changing application files.
On the GitHub runner:
- PHP 8.2 or newer with
curlandzip - a production-ready source directory; dependency installation and frontend builds remain the caller's responsibility
On the server:
- PHP 8.2 or newer with
zip - an HTTPS application URL
- a web document root below the application root, commonly
public/ - write access for PHP to the application root
- explicit FTPS with a verifiable TLS certificate
Build the application first, then pass the prepared directory to the action. Keep deployment secrets in a protected GitHub Environment and do not expose them to pull-request workflows.
name: Deploy
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build production release
run: |
composer install --no-dev --optimize-autoloader
npm ci
npm run build
mkdir -p "$RUNNER_TEMP/release"
rsync -a --exclude='.git/' --exclude='.github/' --exclude='.env*' ./ "$RUNNER_TEMP/release/"
- name: Deploy
uses: tbuck-software/ftps-zip-deploy@FULL_COMMIT_SHA
with:
source-directory: ${{ runner.temp }}/release
ftp-server: ${{ secrets.FTP_SERVER }}
ftp-username: ${{ secrets.FTP_USERNAME }}
ftp-password: ${{ secrets.FTP_PASSWORD }}
application-url: https://example.com
deployment-secret: ${{ secrets.DEPLOYMENT_SECRET }}
environment-file: .env.production
post-deploy-hook: deploy/post-release.php
health-url: https://example.com/upGenerate the deployment secret with at least 32 random bytes, for example:
openssl rand -hex 32Pin this action to a reviewed full commit SHA. A movable tag such as @v1 is convenient but does not provide the same supply-chain guarantee.
The optional hook is a PHP file inside the prepared release. It must return a callable. The server executes it only after authentication, archive verification, safe extraction, and file installation.
<?php
return static function (array $deployment): int {
require $deployment['root'].'/vendor/autoload.php';
$app = require $deployment['root'].'/bootstrap/app.php';
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
return Illuminate\Support\Facades\Artisan::call('migrate', ['--force' => true]);
};Hooks are trusted release code. They must be deterministic, non-interactive, idempotent where practical, and safe to retry. Database migrations should remain backward compatible because filesystem rollback cannot reverse a committed database migration.
All private state owned by the action lives under one directory outside the document root:
.ftps-zip-deploy/
├── incoming/ # release-specific ZIP, environment, and secret files
├── staging/ # verified extraction workspace
├── backups/ # rollback data during a deployment
├── state/
│ └── manifest.json
├── deploy.lock
└── secret
The only public component is the authenticated bootstrap at:
public/.well-known/ftps-zip-deploy/index.php
The .well-known location avoids loose deployment scripts in public/ while remaining compatible with web servers that deny arbitrary dot paths.
| Input | Required | Default | Purpose |
|---|---|---|---|
source-directory |
no | . |
Prepared release tree |
ftp-server |
yes | — | FTPS host and optional port |
ftp-username |
yes | — | FTPS login |
ftp-password |
yes | — | FTPS password |
server-directory |
no | / |
Application root relative to the FTP account |
application-url |
yes | — | HTTPS application base URL |
deployment-secret |
yes | — | HMAC secret, at least 32 characters |
environment-file |
no | empty | Environment file uploaded outside the ZIP |
environment-target |
no | .env |
Private relative destination path |
public-directory |
no | public |
Web document root below the application root |
post-deploy-hook |
no | empty | Relative PHP hook inside the release |
health-url |
no | empty | HTTPS URL checked after deployment |
protected-paths |
no | .env, storage, .git, .github |
Newline-separated persistent or private paths |
release-id |
no | GitHub SHA/run/attempt | Unique protocol identifier |
deployment-commit |
no | github.sha |
Source revision represented by the release |
max-archive-bytes |
no | 256 MiB | Compressed archive limit |
max-uncompressed-bytes |
no | 1 GiB | Extracted release limit |
max-entries |
no | 100,000 | ZIP entry limit |
- Explicit FTPS with certificate and hostname verification
- HTTPS-only deployment and health URLs
- HMAC-SHA256 over a timestamped, bounded JSON payload
- Five-minute signature lifetime and a non-blocking server deployment lock
- SHA-256 verification for the archive and optional environment file
- Release-specific incoming filenames to prevent cross-run overwrites
- ZIP path, entry count, size, compression expansion, and Unix symlink validation
- Source-side rejection of symbolic links and special filesystem entries
- Protected persistent paths and a reserved action namespace
- Automatic exclusion of Git metadata and an in-tree environment input
- Staging validation before application mutation
- Filesystem rollback when installation or the post-deploy hook fails
- Fixed public error codes; exception messages and response bodies are never copied into Actions logs
- Minimal workflow permissions and no dependency on third-party deployment actions
See docs/architecture.md for protocol boundaries and residual risks.
MIT