If you are still dragging PHP files into FileZilla and praying your internet connection holds, you are running a deployment strategy from 2008. At wppoland.com, we have migrated dozens of client sites away from FTP-based workflows, and the difference in reliability, security, and developer sanity is night and day. This guide walks you through every layer of a modern WordPress deployment pipeline - from SSH key authentication to fully automated CI/CD - with real commands you can run today.
Why FTP is unsafe for WordPress deployment
FTP (File Transfer Protocol) transmits credentials and file contents in plain text. Anyone sitting on the same network - a coffee shop, a shared hosting control panel, a compromised router - can intercept your username, password, and every file you upload. Beyond the security nightmare, FTP has no concept of version history, no rollback mechanism, and no way to coordinate work between multiple developers.
SFTP and FTPS patched the encryption gap, but they did not fix the fundamental workflow problems. You are still manually selecting files, hoping you remembered which ones changed, and overwriting production code with no safety net. One interrupted upload of functions.php and your site returns a white screen.
The modern replacement is a three-layer stack: SSH for secure server access, Git for version control and collaboration, and CI/CD pipelines for automated testing and deployment. Together, these tools give you encrypted connections, full change history, instant rollback, and zero-downtime releases. Every professional WordPress agency in 2026 - including wppoland.com - treats this stack as baseline infrastructure, not a luxury.
SSH key authentication setup
Password-based SSH authentication is vulnerable to brute-force attacks. Key-based authentication eliminates that risk entirely. The server only grants access to clients that possess the matching private key - no password ever crosses the network.
Generate an ED25519 key pair
ED25519 keys are shorter, faster, and more secure than older RSA keys. Generate one with a descriptive comment:
ssh-keygen -t ed25519 -C "[email protected]"This creates two files: ~/.ssh/id_ed25519 (private key - never share this) and ~/.ssh/id_ed25519.pub (public key - safe to distribute).
Copy your public key to the server
The quickest method is ssh-copy-id, which appends your public key to the server’s ~/.ssh/authorized_keys file:
ssh-copy-id [email protected]After this, SSH connections to that server will authenticate automatically without a password prompt.
Configure ssh-agent and the config file
Start ssh-agent so you do not have to enter your key passphrase repeatedly:
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519Then create or edit ~/.ssh/config for convenient aliases:
Host wppoland-prod
HostName 198.51.100.42
User deploy
IdentityFile ~/.ssh/id_ed25519
Port 22
Host wppoland-staging
HostName 198.51.100.43
User deploy
IdentityFile ~/.ssh/id_ed25519
Port 22Now ssh wppoland-prod connects you instantly. No IP addresses to remember, no passwords to type. Every developer on your team should have their own key pair - never share private keys between people.
How to set up a WordPress Git repository
Git transforms WordPress development from “edit files on the server and hope for the best” into a structured, auditable process with full change history.
WordPress Git repository structure
Not everything in a WordPress installation belongs in Git. Track your custom code; ignore everything that can be reinstalled:
wordpress-project/
├── .gitignore
├── composer.json
├── wp-content/
│ ├── themes/
│ │ └── wppoland-theme/ # tracked
│ ├── plugins/
│ │ └── wppoland-custom/ # tracked
│ └── mu-plugins/ # tracked
└── wp-config.php # NOT trackedYour .gitignore should exclude WordPress core files, uploads, and sensitive configuration:
/wp-admin/
/wp-includes/
/wp-content/uploads/
/wp-content/upgrade/
wp-config.php
.env
*.log
node_modules/
vendor/Git branching strategy for WordPress
Keep it simple. A main branch represents production. Feature branches (feature/new-header, fix/contact-form) branch off main and merge back through pull requests. For client projects at wppoland.com, we add a staging branch that auto-deploys to the staging server for client review before merging to main.
Composer for WordPress dependency management
Use Composer to manage WordPress plugins and even WordPress core as dependencies. This makes installations reproducible and eliminates the need to commit third-party code:
{
"require": {
"wpackagist-plugin/advanced-custom-fields": "^6.0",
"wpackagist-plugin/wordpress-seo": "^22.0",
"wpackagist-plugin/wp-super-cache": "^1.9"
},
"repositories": [
{
"type": "composer",
"url": "https://wpackagist.org"
}
]
}Run composer install on the server (or during CI) and every environment gets identical plugin versions.
How to deploy WordPress with rsync over SSH
While Git handles version control, rsync is the workhorse for transferring built files to production. It only sends the differences between source and destination, making it dramatically faster than uploading everything via FTP or SCP.
rsync vs SCP vs FTP
SCP copies entire files every time. FTP has no delta transfer. rsync calculates checksums and transfers only changed blocks. It also supports atomic operations - if the transfer is interrupted, you can resume exactly where it stopped.
rsync deploy command with dry run
Always run a dry-run first to preview what will change:
rsync -avz --dry-run --delete \
--exclude='.git' \
--exclude='wp-config.php' \
--exclude='wp-content/uploads' \
--exclude='.env' \
--exclude='node_modules' \
./dist/ user@server:/var/www/html/Review the output. When satisfied, remove --dry-run to execute:
rsync -avz --delete \
--exclude='.git' \
--exclude='wp-config.php' \
--exclude='wp-content/uploads' \
--exclude='.env' \
--exclude='node_modules' \
./dist/ user@server:/var/www/html/The --delete flag removes files from the server that no longer exist in your local build, keeping the deployment clean. The exclude patterns protect uploads, environment config, and version control metadata from being overwritten or deleted.
CI/CD pipelines for WordPress
Continuous Integration and Continuous Deployment automate the entire chain: lint your code, run tests, build assets, and deploy - triggered by a single git push.
GitHub Actions workflow for WordPress deployment
Create .github/workflows/deploy.yml in your repository:
name: Deploy WordPress
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer
- name: Install dependencies
run: composer install --no-dev --optimize-autoloader
- name: Run PHP linting
run: vendor/bin/phpcs --standard=WordPress wp-content/themes/wppoland-theme/
- name: Build frontend assets
run: |
npm ci
npm run build
- name: Deploy via rsync
uses: burnett01/[email protected]
with:
switches: -avz --delete --exclude='.git' --exclude='wp-config.php' --exclude='wp-content/uploads'
path: ./
remote_path: /var/www/html/
remote_host: ${{ secrets.SSH_HOST }}
remote_user: ${{ secrets.SSH_USER }}
remote_key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Clear cache
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: wp cache flush --path=/var/www/htmlStore your SSH credentials in GitHub repository secrets - never hardcode them. Every push to main now triggers a full lint, build, and deploy cycle. If linting fails, the deployment never happens. If rsync fails, you still have your previous working version on the server.
WP-CLI commands for managing WordPress over SSH
WP-CLI lets you manage every aspect of WordPress from the terminal. Combined with SSH, you can administer remote sites without ever opening a browser.
Essential WP-CLI commands
# Update WordPress core
wp core update
wp core update-db
# Plugin management
wp plugin install wordpress-seo --activate
wp plugin update --all
wp plugin deactivate wordfence
# User management
wp user create editor [email protected] --role=editor
wp user update 1 --user_pass=new_secure_password
# Database operations
wp db optimize
wp db repair
# Search and replace (critical for migrations)
wp search-replace 'https://staging.wppoland.com' 'https://wppoland.com' --all-tables --precise
# Generate a salts refresh
wp config shuffle-salts
# Maintenance mode
wp maintenance-mode activateRemote WP-CLI via SSH
Run WP-CLI commands on your production server without logging in first:
ssh wppoland-prod "cd /var/www/html && wp plugin status"
ssh wppoland-prod "cd /var/www/html && wp cron event run --due-now"You can also configure WP-CLI aliases in ~/.wp-cli/config.yml:
@prod:
ssh: [email protected]/var/www/html
@staging:
ssh: [email protected]/var/www/htmlThen run wp @prod plugin list from your local machine.
Database migration and sync
The database is the trickiest part of WordPress deployment because it contains serialized data with hardcoded URLs. A simple find-and-replace in SQL will break serialized strings. WP-CLI handles this correctly.
How to export and import a WordPress database with WP-CLI
# Export from staging
wp @staging db export staging-backup.sql
# Download it
scp wppoland-staging:/var/www/html/staging-backup.sql ./
# Import to production (after backup)
wp @prod db export production-backup-$(date +%Y%m%d).sql
wp @prod db import staging-backup.sql
# Fix URLs with serialization-safe replacement
wp @prod search-replace 'https://staging.wppoland.com' 'https://wppoland.com' --all-tables --preciseHow to migrate a staging database to production
At wppoland.com, our database migration workflow follows a strict sequence: export production database as backup, import the staging database, run search-replace for URLs, flush all caches, and verify critical pages. We script the entire process so it runs in under two minutes with a single command. For larger sites, consider the WP Migrate plugin, which handles serialized data, media files, and table selection through a GUI - useful when clients need to trigger their own migrations.
How to configure WordPress for staging and production
Professional WordPress development requires at least three environments: local development, staging for client review, and production. Each environment needs its own database, its own URL, and its own configuration - but the same codebase.
wp-config.php per environment
Use environment detection in wp-config.php to load the correct settings:
<?php
$environment = getenv('WP_ENVIRONMENT_TYPE') ?: 'production';
switch ($environment) {
case 'local':
define('DB_NAME', 'wppoland_local');
define('DB_HOST', 'localhost');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
break;
case 'staging':
define('DB_NAME', 'wppoland_staging');
define('DB_HOST', 'db.internal');
define('WP_DEBUG', true);
define('WP_DEBUG_LOG', true);
break;
default: // production
define('DB_NAME', 'wppoland_prod');
define('DB_HOST', 'db.internal');
define('WP_DEBUG', false);
break;
}Using .env files with phpdotenv
A cleaner approach keeps secrets out of wp-config.php entirely. Install vlucas/phpdotenv via Composer and load environment variables from a .env file:
DB_NAME=wppoland_prod
DB_USER=wp_user
DB_PASSWORD=strong_random_password
DB_HOST=localhost
WP_HOME=https://wppoland.com
WP_SITEURL=https://wppoland.comEach environment gets its own .env file (never committed to Git), while wp-config.php reads from $_ENV and stays identical across all servers. This pattern, popularized by Bedrock from Roots.io, is now standard practice for WordPress agencies that manage multiple environments per project.
How to harden a WordPress server after deployment
A modern deployment pipeline is only as secure as its weakest link. Once you have SSH keys and automated deploys in place, lock down everything else.
Disable XML-RPC
XML-RPC is a legacy API that enables brute-force amplification attacks. Unless you specifically need it (Jetpack, some mobile apps), disable it:
add_filter('xmlrpc_enabled', '__return_false');Set correct file permissions
WordPress files should never be world-writable:
# Files: 644 (owner read/write, group and others read)
find /var/www/html -type f -exec chmod 644 {} \;
# Directories: 755 (owner full, group and others read/execute)
find /var/www/html -type d -exec chmod 755 {} \;
# wp-config.php: extra restrictive
chmod 600 /var/www/html/wp-config.phpMove wp-config.php above webroot
WordPress automatically checks one directory above the webroot for wp-config.php. Moving it there prevents direct access via URL:
mv /var/www/html/wp-config.php /var/www/wp-config.phpProtect SSH with fail2ban
Install fail2ban to automatically ban IPs that fail SSH authentication repeatedly:
sudo apt install fail2ban
sudo systemctl enable fail2banThe default configuration bans an IP for 10 minutes after 5 failed attempts. For production servers at wppoland.com, we increase the ban time to 1 hour and reduce the retry threshold to 3 attempts.
Why you should avoid running git pull directly in production
A common transitional habit for developers migrating away from FTP is logging into the production server via SSH and running git pull origin main directly inside the document root (/var/www/html). While this brings source control history, it introduces severe operational hazards in high-traffic environments.
Inconsistent runtime state during network fetch
Git updates files sequentially on the filesystem during a pull. If a deployment touches both a theme template and a core business plugin with interdependent function signatures, PHP-FPM processes will execute incoming HTTP requests against a partially updated codebase. Visitors and search engine crawlers encounter Fatal error: Uncaught Error: Call to undefined function and broken layouts during the multi-second window of the pull.
Merge conflicts caused by runtime file modifications
If an administrator installs an emergency patch, an automatic localization update runs, or a misconfigured plugin writes to a tracked directory on the production server, the subsequent git pull aborts with merge conflicts (error: Your local changes to the following files would be overwritten by merge). The automated deployment halts mid-stream, leaving production in an indeterminate, unmanaged state until an engineer manually resolves conflicts over SSH.
Public exposure of repository metadata
Running Git directly inside the public web root creates a hidden .git/ folder. Unless the web server configuration explicitly blocks dot-folders, malicious actors can download .git/index and reconstruct your entire source tree, retrieving sensitive architecture comments, database credentials, and internal endpoint paths.
Atomic deployment architecture using symbolic links
To achieve zero downtime and eliminate the runtime inconsistencies of git pull or FTP uploads, professional WordPress platforms adopt an atomic deployment pattern derived from Capistrano and Deployer.
Directory structure for zero-downtime releases
Atomic release architecture decouples individual build releases, persistent shared assets, and the public web root:
/var/www/mysite/
├── current -> /var/www/mysite/releases/20261012120000/
├── releases/
│ ├── 20261012100000/
│ ├── 20261012110000/
│ └── 20261012120000/
└── shared/
├── .env
└── wp-content/
└── uploads/The web server (Nginx or Apache) points its root directive exclusively to /var/www/mysite/current.
Atomic symlink switching mechanics
- Each deployment provisions a pristine directory inside
releases/labeled with an ISO timestamp. - Symbolic links are created linking shared persistent assets from
shared/, including.envandwp-content/uploads/. - Dependency installation (
composer install), frontend bundling, database updates (wp core update-db), and automated smoke tests execute within the isolated release folder. - Once the release passes all health checks, the
currentsymlink switches atomically in a single POSIX filesystem operation:
ln -sfn /var/www/mysite/releases/20261012120000 /var/www/mysite/current_temp && mv -Tf /var/www/mysite/current_temp /var/www/mysite/currentThe underlying rename() syscall in Linux is atomic. For incoming HTTP requests, the switch takes microseconds. If any build step or dependency installation fails earlier in the pipeline, the current pointer remains untouched, and production continues serving traffic without a split second of downtime.
Instant rollback strategy for deployment emergencies
If an undetected bug emerges after deployment, reverting to the previous known good state does not require re-running builds or pulling database backups. You switch the symlink back to the prior release folder and clear caches:
ln -sfn /var/www/mysite/releases/20261012110000 /var/www/mysite/current
wp cache flush
sudo systemctl reload php8.3-fpmAutomated deployment with Git bare repositories and post-receive hooks
For infrastructure setups without external SaaS CI/CD runners, you can build a lightweight push-to-deploy workflow directly on your Linux host using a Git bare repository and a post-receive server hook.
Setting up a bare repository on the server
On the target server, initialize an administrative repository without a working tree:
mkdir -p /var/repo/mysite.git
cd /var/repo/mysite.git
git init --bareImplementing the post-receive hook script
Create the executable script at /var/repo/mysite.git/hooks/post-receive:
#!/bin/bash
TARGET_DIR="/var/www/mysite/releases/$(date +%Y%m%d%H%M%S)"
SHARED_DIR="/var/www/mysite/shared"
CURRENT_LINK="/var/www/mysite/current"
echo "===> Initializing WordPress atomic release..."
mkdir -p "$TARGET_DIR"
# Extract codebase from main branch
git --work-tree="$TARGET_DIR" --git-dir=/var/repo/mysite.git checkout -f main
# Link shared persistent assets
ln -s "$SHARED_DIR/.env" "$TARGET_DIR/.env"
rm -rf "$TARGET_DIR/wp-content/uploads"
ln -s "$SHARED_DIR/wp-content/uploads" "$TARGET_DIR/wp-content/uploads"
# Install Composer production dependencies
if [ -f "$TARGET_DIR/composer.json" ]; then
cd "$TARGET_DIR" && composer install --no-dev --optimize-autoloader --quiet
fi
# Run WordPress database migrations
wp core update-db --path="$TARGET_DIR" --quiet
# Atomically switch the current release pointer
ln -sfn "$TARGET_DIR" "${CURRENT_LINK}_temp" && mv -Tf "${CURRENT_LINK}_temp" "$CURRENT_LINK"
# Flush application object cache and reload PHP-FPM
wp cache flush --path="$CURRENT_LINK" --quiet
sudo systemctl reload php8.3-fpm
echo "===> Release deployed successfully with zero downtime."Make the script executable:
chmod +x /var/repo/mysite.git/hooks/post-receiveConfiguring local remote and deploying
On your local development machine, add the remote endpoint:
git remote add production deploy@server:/var/repo/mysite.git
git push production mainGit pushes compressed cryptographic deltas over SSH, triggers the server hook, builds the release in isolation, and cuts over the symlink automatically.
Post-deployment verification and automated smoke tests
A modern deployment pipeline does not terminate when files finish syncing. Automated post-release validation ensures that application runtime components respond as expected.
Automated HTTP health checks
Execute automated status code verifications against primary landing pages and API endpoints immediately after cache flushing:
# Verify key endpoints respond with HTTP 200
curl -s -o /dev/null -w "%{http_code}" https://wppoland.com/
curl -s -o /dev/null -w "%{http_code}" https://wppoland.com/en/wordpress-developer/
curl -s -o /dev/null -w "%{http_code}" https://wppoland.com/wp-json/If any critical endpoint returns a 500 error code, your pipeline can automatically trigger the atomic rollback command before real visitors notice the regression.
Monitoring runtime error logs
Tail the PHP-FPM error log and WordPress debug log for several minutes following a release to catch hidden notices, deprecation warnings, or fatal hook conflicts:
tail -n 100 /var/log/php8.3-fpm.log
tail -n 100 /var/www/mysite/shared/wp-content/debug.logSecure secret management across the deployment pipeline
Transitioning to Git requires strict governance over application secrets and database credentials:
- Private SSH keys remain on local developer workstations: Protect private keys with strong passphrases and store them in secure hardware or local keychains. Only public keys reside in the server’s
authorized_keys. - Pipelines use dedicated deploy keys: Automated CI/CD workers (such as GitHub Actions) must use dedicated read-only deploy keys or scoped SSH credentials rather than personal developer credentials.
- Environment variables in isolated configuration files: Database passwords and API keys belong in
/var/www/mysite/shared/.envwith strict600file permissions. If credentials are accidentally committed to Git, removing them in a subsequent commit does not secure the system. Because Git preserves complete commit histories, committed secrets remain permanently exposed and must be rotated immediately.
FTP to Git migration checklist for WordPress
Moving from FTP to a professional deployment workflow does not happen overnight. Use this checklist to migrate incrementally, one step at a time.
Phase 1 - Foundation (day 1)
- Generate ED25519 SSH keys for every developer
- Disable password authentication on the server (
PasswordAuthentication noinsshd_config) - Install WP-CLI on the server
- Configure
~/.ssh/configfor all environments
Phase 2 - Version control (week 1)
- Initialize a Git repository for the project
- Create a proper
.gitignore(exclude core, uploads, config) - Move plugin/theme management to Composer where possible
- Establish a branching strategy (
main+ feature branches)
Phase 3 - Automated deployment (week 2)
- Set up a staging environment that mirrors production
- Write an rsync deployment script with proper excludes
- Create a GitHub Actions workflow for automated deploys
- Add PHP linting and coding standards checks to the pipeline
Phase 4 - Hardening (week 3)
- Configure per-environment
wp-config.phpor.envfiles - Set file permissions (644 files, 755 directories)
- Install and configure fail2ban
- Disable XML-RPC and unused REST API endpoints
- Document the entire workflow for your team
Every site we manage at wppoland.com follows this progression. Clients who started with FileZilla now have push-to-deploy pipelines with automatic rollback. The upfront investment is a few hours; the return is years of faster, safer, and less stressful deployments.
Learn more about our WordPress development services at WPPoland.







