A self-hosted, lightweight deployment panel for PHP applications on shared cPanel hosting. Deploy registered web projects from GitHub (or manual zip uploads) to your server — safely, traceably, and reversibly — with no SSH or external CI/CD required.
The Goal: TeraPH WD should be the best deploy panel for shared/cPanel PHP hosting and a perfect fit for VPS too
- Requirements
- Quick Start (Local Dev)
- Production Installation
- Bootstrap Installer
- Configuration Reference
- GitHub PAT Setup
- Adding Your First Project
- Deployment Modes
- Deploy Templates
- Deploy Hooks
- .env Manager
- Web Terminal
- Deployer Self-Protection
- Deploy Strategy
- General Settings
- CLI Reference
- Project Structure
- Troubleshooting
- Security Checklist
- Documentation
| Requirement | Minimum |
|---|---|
| PHP | 8.1+ |
| PHP Extensions | pdo, pdo_sqlite or pdo_mysql, zip, curl |
| Web Server | Apache with mod_rewrite + .htaccess support |
| Hosting | Any cPanel shared hosting, or a local PHP environment |
Verify your extensions are present:
php -m | grep -E "pdo|zip|curl"cd /your/projects/folder
# Copy or extract TeraPH Web Deployer herecp config.php.example config.phpphp tera make:password yourpasswordCopy the output hash — you will paste it into config.php next.
Open config.php and update the following values at minimum:
define('APP_PASSWORD_HASH', 'paste-your-hash-here');
// For local dev, SSL verification is disabled by default:
define('DEV_MODE', true);See the full Configuration Reference below for all options.
php tera serve
# Or on a custom port:
php tera serve 8080http://localhost:8000
Log in with:
- Username:
admin(set byAPP_USERNAMEinconfig.php) - Password: whatever you used in step 3
The database and storage directories are created automatically on first login.
Upload the entire project to a private path on your server, outside public_html:
/home/youraccount/deployer/ ← project root
/home/youraccount/deployer/public ← point your (sub)domain here
Do not place the root inside
public_htmlwithout additional server-level protection.
In cPanel → Subdomains or Addon Domains, set the document root for your deploy panel
subdomain (e.g. deploy.yourdomain.com) to:
/home/youraccount/deployer/public
// config.php
define('DEV_MODE', false); // ← must be false in production
define('CURL_SSL_VERIFY', true); // ← SSL verification enabled
define('DB_DRIVER', 'sqlite'); // or 'mysql'
define('APP_PASSWORD_HASH', '...'); // generate with: php tera make:passwordVia SSH:
php tera migrateVia browser (when SSH isn't available):
Navigate to https://deploy.yourdomain.com/migrate.php. It will prompt for your password, run all pending migrations, and print the output.
Open https://deploy.yourdomain.com and log in.
If you don't have SSH access or want a faster browser-based setup, use the single-file bootstrap installer. Drop it anywhere web-accessible, visit it in a browser, and it downloads + configures the deployer for you.
- Download
scripts/install.phpfrom this repository - Upload it to your server — for example,
public_html/install.php - Visit
https://yourdomain.com/install.phpin a browser - Fill in the form:
- Install Directory — absolute server path where the deployer will be extracted (default: a
deployer/folder next to the installer) - Dashboard URL — auto-detected, correct it if needed
- Admin Username and Password
- Install Directory — absolute server path where the deployer will be extracted (default: a
- Click Download & Install
- When done, click Delete install.php from server — the installer has no auth protection and must be removed
- Downloads the latest deployer zip from GitHub (
mainbranch) - Extracts and copies all files to the chosen install directory
- Generates
config.phpwith your credentials —DEV_MODEoff, SSL verification on - Creates all
storage/subdirectories and blocks direct web access to them - Database migrations run automatically on your first login
Security:
install.phphas no authentication. Delete it immediately after installation — the installer page itself offers a one-click delete button.
All configuration lives in config.php. Copy from config.php.example and edit.
Never commit config.php to version control — it is gitignored by default.
define('APP_NAME', 'TeraPH Deployer'); // Panel title shown in the UI
define('APP_URL', ''); // Full URL to /public/ (used for links, optional)define('APP_USERNAME', 'admin'); // Login username
define('APP_PASSWORD_HASH', '...'); // bcrypt hash — generate with: php tera make:passwordGenerate a hash any time:
php tera make:password yournewpassworddefine('SESSION_NAME', 'tera_deployer_session');
define('SESSION_TIMEOUT', 3600); // Idle timeout in seconds (default: 1 hour)The default is SQLite — zero setup required:
define('DB_DRIVER', 'sqlite');
define('DB_PATH', __DIR__ . '/storage/deployer.db');To switch to MySQL / MariaDB:
define('DB_DRIVER', 'mysql');
define('DB_HOST', 'localhost');
define('DB_PORT', '3306');
define('DB_NAME', 'deployer');
define('DB_USER', 'root');
define('DB_PASS', 'yourpassword');define('STORAGE_PATH', __DIR__ . '/storage');
define('BACKUP_PATH', __DIR__ . '/storage/backups');
define('TMP_PATH', __DIR__ . '/storage/tmp');
define('LOG_PATH', __DIR__ . '/storage/logs');All of these are blocked from public web access via .htaccess.
define('MAX_BACKUPS_PER_PROJECT', 10); // Oldest backups auto-deleted past this count
define('DOWNLOAD_TIMEOUT', 300); // Max seconds to wait for GitHub zip download
define('LOCK_TIMEOUT', 1200); // Seconds before a stuck deploy lock is auto-released
define('HOOK_TIMEOUT', 300); // Max seconds a single pre/post deploy hook may runControls how files are moved into the target directory on each deploy.
// 'auto' → test symlink capability on first deploy, cache the result
// 'symlink' → force atomic symlink-based releases (requires host support)
// 'copy' → always use in-place file copy (safe fallback for shared hosting)
define('DEPLOY_STRATEGY', 'auto');In auto mode the result of a one-time symlink test is cached in storage/deploy_strategy.txt.
See Deploy Strategy for details.
// Absolute path to the deployer installation root.
// Used by the self-protection guard to prevent the deployer from
// deleting itself when it lives inside a project's target directory.
define('DEPLOYER_ROOT', dirname(__DIR__));See Deployer Self-Protection for details.
define('TERMINAL_ENABLED', true); // false = disable web terminal site-wide
define('TERMINAL_TIMEOUT', 600); // Max seconds a single terminal command may rundefine('GITHUB_PAT', 'ghp_xxxxxxxxxxxxxxxx');A per-project PAT set in the project form takes priority over this global value.
define('DEV_MODE', true); // true = local dev, false = production
define('CURL_SSL_VERIFY', !DEV_MODE);DEV_MODE = true disables SSL certificate verification in cURL. Needed on Windows
local machines where PHP does not ship with a CA certificate bundle.
Always set
DEV_MODE = falsebefore deploying to production.
A Personal Access Token (PAT) allows the deployer to download zip archives from private GitHub repositories. Two types are supported.
- Go to GitHub → Settings (avatar, top-right) → Developer settings
- Personal access tokens → Tokens (classic)
- Click Generate new token (classic)
- Set an expiry and tick the
reposcope - Click Generate token and copy it immediately
Classic PATs start with ghp_.
Fine-grained tokens are more secure but require precise setup.
Follow every step exactly — missing any one causes a 404 error.
- Go to GitHub → Settings → Developer settings → Fine-grained tokens
- Click Generate new token
Resource owner: [select the account/org that OWNS the repository]
This must match the GitHub user or org that owns the private repo.
If the resource owner is an organisation, the org admin must approve your PAT at:
github.com/organizations/{org-name}/settings/personal-access-tokens/pending
Repository access: Only select repositories → [Add] your-repo-name
Repository permissions:
Metadata → Read-only (auto-enabled)
Contents → Read-only (required for zip download)
Fine-grained tokens start with github_pat_.
php tera github:test <project-name>Expected output:
[1/2] Checking if PAT is valid (GET /user)...
OK — PAT is valid. Authenticated as: yourusername
[2/2] Testing repo access (owner/repo)...
Result: HTTP 200 -- OK
[OK] Access confirmed. Deployment should work.
-
Click + Add Project on the dashboard
-
Fill in the form:
Field Description Project Name Unique slug (letters, numbers, hyphens, underscores) Source Type GitHub RepositoryorManual Zip UploadRepository URL Full GitHub HTTPS URL (GitHub source type only) Branch Default: main(GitHub source type only)Target Directory Absolute path on server (e.g. /home/user/public_html/myapp)Safe-Keep Paths Comma-separated files/dirs preserved during Safe deploys (e.g. .env, uploads/, storage/)GitHub PAT Personal Access Token — required for private repos Deploy Template Pre-fills hook commands for a framework — none,laravel,codeigniter,wordpress,symfonyPre-Deploy Hooks Commands run after files are copied, before permissions are set Post-Deploy Hooks Commands run after permissions are set Enable Terminal Whether the web terminal is available for this project -
Click Save Project
-
Click ▶ Deploy on the project card
-
Select mode (
SafeorFull), confirm, and monitor the live log stream -
Click Logs on the project card to view the full deployment log
PAT tip: Leaving the GitHub PAT field blank when editing a project preserves the stored token. The placeholder reads "PAT saved — leave blank to keep" to confirm this.
| Mode | Behaviour | When to use |
|---|---|---|
| Safe | Preserves files listed in Safe-Keep Paths (.env, uploads/, etc.), replaces only code files |
Normal updates to live sites |
| Full | Replaces the entire target directory with the new code | Fresh installs, or when a clean slate is needed |
⚠ Full mode permanently deletes all existing files in the target directory. A backup is always created first, but proceed carefully.
Both modes:
- Download the repository zip from GitHub (or use the saved manual archive)
- Create a timestamped zip backup before touching any files
- Log every step to the database and to a flat
.logfile - Acquire a deployment lock (prevents concurrent deploys on the same project)
- Run pre-deploy hooks after copying files (if configured)
- Run post-deploy hooks after setting permissions (if configured)
- Automatically release the lock on success or failure
Selecting a template in the project form auto-fills the pre/post hook textareas with the standard commands for that framework. You can edit the commands after they are inserted.
| Template | Pre-Deploy Hooks | Post-Deploy Hooks |
|---|---|---|
| none | (empty — manual) | (empty — manual) |
| laravel | {composer} install --no-dev --optimize-autoloader, php artisan config:cache, php artisan route:cache, php artisan view:cache, php artisan migrate --force |
php artisan queue:restart, php artisan cache:clear |
| codeigniter | {composer} install --no-dev, php spark migrate |
(empty) |
| wordpress | {composer} install --no-dev |
(empty) |
| symfony | {composer} install --no-dev --optimize-autoloader, php bin/console doctrine:migrations:migrate --no-interaction, php bin/console cache:clear |
(empty) |
Switching templates in the form asks for confirmation before overwriting any hooks you have already typed.
Pre and post-deploy hooks are shell commands that run as part of the deployment pipeline, executed inside the project's target directory.
Pre-deploy hooks run immediately after new files are copied into the target directory. Post-deploy hooks run after file permissions have been set.
If any hook exits with a non-zero code the pipeline halts and the deployment is marked failed. All hook output is streamed to the deployment log in real time.
Use {composer} instead of a hardcoded path in hook commands. At runtime it resolves to:
php composer.phar— if acomposer.pharexists in the project's target directorycomposer— if the global binary is available (which composer/where composer)php composer.phar— fallback (download it via the web terminal toolbar)
{composer} install --no-dev --optimize-autoloader
{composer} dump-autoloadHook commands are stored one per line in the project form. Lines starting with # and blank lines are ignored.
Each hook command is subject to HOOK_TIMEOUT (default 300 seconds). Set it higher for slow composer installs on restricted hosts.
Every project has an .env Manager page (env_manager.php) for editing environment
variables from the browser instead of hand-editing files on the server.
- Off by default. Toggle Managed .env on for a project and the deployer writes a
fresh
.envfile to the target directory on every deploy from then on. - Template + values. Paste your
.env.example(or a full.env) into the Template card, or click Import from .env.example to read one that already exists in the target directory. Saving extracts everyKEY=valueline; keys are kept in sync automatically — add a line, get a new field; remove a line, the stored value is deleted. - Values are encrypted at rest (AES-256-GCM) and masked in the UI by default — never written to any deploy log.
- Required keys halt the deploy before any files are touched if left empty.
Set a dedicated encryption key before storing real values:
// config.php
define('SECRET_KEY', 'paste output of: php -r "echo bin2hex(random_bytes(32));"');Without a SECRET_KEY, values are encrypted with a key derived from APP_PASSWORD_HASH —
changing the admin password later would make all stored values permanently unreadable. The
.env Manager shows a warning banner whenever this is the case. Full usage walkthrough:
docs/USER.md. Encryption details: docs/SECURITY.md.
Every project card has a >_ Terminal button that opens a web-based shell running commands inside the project's target directory.
- Shortcut toolbar — one-click buttons for common commands (
{c} install,{c} dump-autoload,artisan migrate,artisan config:cache,artisan queue:restart) {composer}resolution — type{composer}in any command and it resolves to the correct binary automatically- Download composer.phar — the ↓ composer.phar toolbar button downloads the latest stable
composer.pharfromgetcomposer.orgdirectly into the project's target directory, with SHA-256 verification - Audit log — every command is logged to the
terminal_logsdatabase table (with user, IP, command, and exit code), with a flat-file fallback tostorage/logs/terminal_audit.log - Streaming output — stdout and stderr stream to the browser in real time
Site-wide:
define('TERMINAL_ENABLED', false); // config.phpPer-project: uncheck Enable web terminal in the project form.
define('TERMINAL_TIMEOUT', 600); // seconds — default 10 minutesWhen the deployer lives inside the same directory as an application being deployed (a common shared-hosting layout), it would normally delete itself during a Full deploy or overwrite itself during a Safe deploy.
TeraPH automatically detects this scenario and protects itself:
- During deploy — the deployer's own directory is silently added to the skip list, so it is never cleared or overwritten regardless of deploy mode
- During backup — the deployer directory is excluded from the pre-deploy zip so backups only contain the application code
- Dashboard badge — project cards show an ⚠ Deployer inside target badge whenever this overlap is detected
// Set this to the absolute path of the deployer's root directory.
// The default (dirname(__DIR__)) is correct for standard installations.
define('DEPLOYER_ROOT', '/home/user/public_html/deployer');The detection uses realpath() so it is symlink-aware and path-separator agnostic.
TeraPH supports two strategies for applying files to the target directory:
| Strategy | How it works | Best for |
|---|---|---|
| copy | Files are copied in-place (existing files are cleared first) | All hosting environments — the safe default |
| symlink | Atomic release via symlink swap | VPS / servers with symlink support |
define('DEPLOY_STRATEGY', 'auto'); // defaultIn auto mode, TeraPH tests whether symlink() works in the server's temp directory on the first deployment. The result is cached in storage/deploy_strategy.txt — you can delete this file to force a re-test.
define('DEPLOY_STRATEGY', 'copy'); // always use copy — safe on all cPanel hosts
define('DEPLOY_STRATEGY', 'symlink'); // always use symlinkThe resolved strategy is logged at the start of every deployment:
Deploy strategy: copy
The Settings page (/settings.php, linked in the topbar) shows a read-only overview of the active installation:
| Section | Information shown |
|---|---|
| Installation | Deployer root path, dev mode status |
| Deploy | Strategy setting + resolved strategy (auto-detected result) |
| Terminal | Site-wide enabled state, command timeout |
| Hooks | Hook timeout |
| Backups | Max backups per project |
| Database | Driver in use |
The Settings page also provides quick-action buttons:
- Run Migrations — triggers a migration run without needing CLI access
- Self-Update — downloads and applies the latest code from GitHub, preserving
config.phpandstorage/
All commands are run from the project root:
php tera <command> [options]| Command | Description |
|---|---|
php tera serve |
Start the PHP dev server on localhost:8000 |
php tera serve 8080 |
Start on a custom port |
php tera migrate |
Run all pending database migrations |
php tera make:password |
Interactively hash a password for config.php |
php tera make:password mypass |
Hash a password non-interactively |
php tera github:test <project> |
Two-step PAT diagnostic (validates token + repo access) |
php tera deploy:reconcile |
Find and fix all deployments stuck in running state |
php tera clear:tmp |
Safely clear the temporary cache directory |
php tera clear:archives |
Clear old manually-uploaded deployment ZIP files |
php tera clear:backups |
Clear stored pre-deployment backups |
php tera clear:logs |
Clear the raw /storage/logs directory |
php tera clear:all |
Run all clearing sweeps sequentially |
php tera help |
Show all available commands |
[1/2] Checking if PAT is valid (GET /user)...
OK - PAT is valid. Authenticated as: tumbujohn
[2/2] Testing repo access (owner/repo)...
Result: HTTP 200 -- OK
[OK] Access confirmed. Deployment should work.
Use after a server crash or process kill where a deployment is stuck at ⟳ Deploying.
php tera deploy:reconcileFor each stuck deployment it prints the last log entry, marks the status failed, and writes an audit log entry.
/deployer
├── .htaccess # HTTPS enforcement, blocks config.php access
├── README.md
├── config.php # Active config — GITIGNORED
├── config.php.example # Template — commit this, not config.php
├── tera # CLI entry point: php tera <command>
├── server.php # PHP built-in dev server router
│
├── scripts/
│ └── install.php # Single-file bootstrap installer (drop on server, run in browser)
│
├── app/ # NOT web-accessible (.htaccess blocks it)
│ ├── bootstrap.php # Single require-once for all classes
│ ├── Auth.php # Session auth, idle timeout
│ ├── Database.php # PDO factory (SQLite / MySQL)
│ ├── helpers.php # CSRF, flash messages, formatters, self-protection helpers
│ ├── Models/
│ │ ├── Project.php # getDeployHooks(), getSafeKeepPaths()
│ │ ├── Deployment.php
│ │ └── DeploymentLog.php
│ └── Services/
│ ├── GitHubService.php # cURL zip download with PAT support
│ ├── FileManager.php # File system operations
│ ├── BackupService.php # Zip backup and restore (deployer-aware)
│ ├── HookRunner.php # Pre/post hook execution, {composer} resolution
│ └── DeployService.php # 10-step deployment pipeline with self-protection
│
├── migrations/ # NOT web-accessible — additive-only, re-run every request
│ ├── 001_initial_schema.php # Core schema (projects, deployments, logs)
│ ├── 002_manual_upload_support.php # Manual upload source type
│ ├── 003_project_hooks_terminal.php # deploy_template, pre/post hooks, terminal_logs
│ └── 004_project_env_manager.php # env_mode, env_template, project_env_vars
│
├── public/ # Web root — point your domain here
│ ├── index.php # Dashboard (overlap badge, template form, terminal toolbar)
│ ├── login.php / logout.php
│ ├── deploy.php # AJAX: POST=run pipeline, GET?action=status
│ ├── logs.php # AJAX: ?deployment_id=X or ?project_id=X
│ ├── backups.php # AJAX backup list + restore
│ ├── projects.php # AJAX project CRUD (template + hook fields)
│ ├── settings.php # General Settings page (read-only install overview)
│ ├── settings_action.php # AJAX: clear_tmp, deploy_reconcile
│ ├── env_manager.php # .env Manager — per-project template + encrypted values
│ ├── migrate.php # Browser-based migration runner
│ ├── update.php # Self-update from GitHub
│ ├── terminal_execute.php # Streaming terminal endpoint (audited, guarded)
│ ├── composer_download.php # Download + verify composer.phar into a project directory
│ ├── webhook.php # Webhook stub (not yet implemented — see docs/TODO.md)
│ └── assets/
│ ├── css/app.css
│ ├── js/app.js # Deploy flow, project form, template auto-fill
│ └── js/terminal.js # Terminal streaming, shortcut toolbar, composer.phar download
│
├── storage/ # NOT web-accessible (.htaccess blocks it)
│ ├── backups/ # Pre-deploy zip backups
│ ├── tmp/ # Temporary zip downloads
│ ├── logs/ # Flat deployment log files + terminal audit log
│ ├── archives/ # Saved manual upload zips
│ ├── deploy_strategy.txt # Cached symlink capability result (auto mode)
│ └── deployer.db # SQLite database (if using SQLite)
│
├── scripts/
│ ├── install.php # Single-file bootstrap installer
│ ├── probe.php # CLI-only install diagnostics — delete after use
│ └── reset_db.php # CLI-only full data wipe for clean testing
│
├── test/
│ └── bootstrap_test.php # CLI diagnostic script
│
└── docs/ # See "Documentation" section below
├── ARCHITECTURE.md
├── REQUIREMENTS.md
├── DEVELOPMENT.md
├── USER.md
├── PROGRESS.md
├── TODO.md
├── DECISIONS.md
├── CHANGELOG.md
├── SECURITY.md
└── archive/ # Superseded Phase-1 planning documents
php tera deploy:reconcileThen reload the dashboard.
The dashboard always queries GET deploy.php?action=status after a deploy attempt,
so the card reflects the real DB status even if the browser lost the original response. If both fail:
- Check the browser console (
F12 → Console) for a[deploy]error line - Check
storage/logs/deployment_N.logfor the pipeline's last recorded step - Run
php tera deploy:reconcileto clean up any stuck state
| Symptom | Cause | Fix |
|---|---|---|
| 404 with a classic PAT | PAT may have expired | Regenerate and update the project's PAT field |
| 404 with a fine-grained PAT | Resource owner mismatch or org approval pending | See Fine-Grained PAT checklist |
| 401 Unauthorized | Token is invalid | Run php tera github:test <project> to diagnose |
php tera github:test <project-name>If a hook command fails with command not found, check:
- Use
{composer}instead ofcomposer— on many shared hosts the global binary is not onPATH - If
{composer}still fails, downloadcomposer.pharusing the ↓ composer.phar button in the web terminal toolbar - Check
HOOK_TIMEOUT— slowcomposer installon restricted hosts may need a higher value
Increase TERMINAL_TIMEOUT in config.php:
define('TERMINAL_TIMEOUT', 1200); // 20 minutesFor deploy hook timeouts, increase HOOK_TIMEOUT instead.
SSL certificate problem: unable to get local issuer certificate
Ensure DEV_MODE = true in config.php. Never use this on a production server.
The pipeline fails at the file-copy step if the target directory does not exist.
- Production: ensure the
public_html/myappdirectory exists on the server - Local dev: create a test folder and point the project's Target Directory field to it
Ensure DEPLOYER_ROOT in config.php is set to the correct absolute path of the deployer installation. Once set correctly, the self-protection guard automatically adds the deployer directory to the skip list and the dashboard will show an ⚠ Deployer inside target badge on the affected project card.
Before going live, verify each item:
-
config.phpis not committed to version control (check.gitignore) -
APP_PASSWORD_HASHis changed from the default -
DEV_MODEis set tofalsein production - The deployer URL is on a subdomain, not in the main
public_html -
storage/,app/, andmigrations/return 403 Forbidden when accessed directly in a browser - HTTPS is enforced (the root
.htaccessdoes this automatically with Apachemod_rewrite) -
scripts/install.phphas been deleted after installation (or was never uploaded) - GitHub PAT has the minimum required scope (
repofor classic,Contents: Readfor fine-grained) - PAT is stored per-project (not only in
config.php) for isolation -
TERMINAL_ENABLEDisfalseif the web terminal is not needed in production -
MAX_BACKUPS_PER_PROJECTis set to a value your disk can sustain - Run
php test/bootstrap_test.php— all checks pass - Run
php tera deploy:reconcile— returns "No stuck deployments found" - A dedicated
SECRET_KEYis set inconfig.phpbefore storing any real.envManager values
See docs/SECURITY.md for the full security architecture, including known gaps (GitHub PATs are currently stored unencrypted — see there for mitigation).
This README covers installation and day-to-day configuration. Deeper documentation lives
under /docs:
| Document | Purpose |
|---|---|
| USER.md | End-user workflows — deploying, backups, terminal, .env manager |
| DEVELOPMENT.md | Setup, conventions, CLI reference, testing, debugging |
| ARCHITECTURE.md | System design, data model, pipeline internals |
| REQUIREMENTS.md | Current functional & non-functional requirements |
| PROGRESS.md | What's implemented vs. planned right now |
| TODO.md | Prioritized outstanding work |
| DECISIONS.md | Why things were built the way they were |
| CHANGELOG.md | Historical record of meaningful changes |
| SECURITY.md | Security architecture, controls, known limitations |
| archive/ | Superseded Phase-1 planning documents, kept for history |