Say goodbye to 'Total Commander Error 29'. A guide to zero-downtime deployments, SSH keys security, and Git workflows.
EN

Stop using FTP: Modern WordPress deployment with SSH, Git & keys

5.00 /5 - (24 votes )
Last verified: May 1, 2026
11min read
Guide

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.

#FTP is dead, here is what replaced it

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 "deploy@wppoland.com"

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 user@server.example.com

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_ed25519

Then 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 22

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

#Git-based WordPress workflow

Git transforms WordPress development from “edit files on the server and hope for the best” into a structured, auditable process with full change history.

#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 tracked

Your .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/

#Branching strategy

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

#Deploying 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.

#Why rsync beats the alternatives

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.

#The deployment command

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

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/rsync-deployments@6.0.0
        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/html

Store 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, the command-line power tool

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 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 editor@wppoland.com --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 activate

#Remote 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: deploy@198.51.100.42/var/www/html
@staging:
  ssh: deploy@198.51.100.43/var/www/html

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

#Export and import

# 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 --precise

#Staging to production workflow

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.

#Environment management

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

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

#Security hardening your 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.php

#Move 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.php

#Protect SSH with fail2ban

Install fail2ban to automatically ban IPs that fail SSH authentication repeatedly:

sudo apt install fail2ban
sudo systemctl enable fail2ban

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

#The modern deployment checklist

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 no in sshd_config)
  • Install WP-CLI on the server
  • Configure ~/.ssh/config for 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.php or .env files
  • 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.

Next step

Turn the article into an actual implementation

This block strengthens internal linking and gives readers the most relevant next move instead of leaving them at a dead end.

Want this implemented on your site?

If you want to convert the article into a working site improvement, redesign, or build plan, I can define the scope and implement it.

Article FAQ

Frequently Asked Questions

Practical answers to apply the topic in real execution.

SEO-ready GEO-ready AEO-ready 3 Q&A
How long does it take to switch from FTP to SSH and Git deployments?
Around 45 minutes for a single site if your hosting allows SSH access. The first move is generating an ed25519 key, copying it to the server, and pushing your repo. Atomic releases with symlinks usually take an extra hour to wire up properly.
What is the minimum tooling required for a modern deployment?
An SSH-capable host, a Git repository (GitHub, GitLab, or self-hosted), and a local terminal. GitHub Actions or any CI runner is optional but recommended for fully automated pushes.
What can go wrong when migrating from FTP to Git?
If wp-content/uploads is committed by mistake, the repository balloons in size. Mismatched file ownership between Git pulls and the web server breaks file uploads. And without atomic symlink switches, half-pulled releases can leave the site in a broken state for several seconds during deploy.

Need an FAQ tailored to your industry and market? We can build one aligned with your business goals.

Let’s discuss

Related Articles

Complete guide to installing WordPress with Docker Compose and Composer (Bedrock). Includes full docker-compose.yml, Xdebug configuration, .env setup, and deployment workflows from local to production.
development

Install WordPress with Docker and Composer: modern dev setup for 2026

Complete guide to installing WordPress with Docker Compose and Composer (Bedrock). Includes full docker-compose.yml, Xdebug configuration, .env setup, and deployment workflows from local to production.

Learn how to create a WordPress staging site, push staging to live safely, and deploy from local development. Covers hosting staging, plugins, WP-CLI, git workflows, and CI/CD with GitHub Actions.
development

WordPress staging site workflow: from local development to production deployment

Learn how to create a WordPress staging site, push staging to live safely, and deploy from local development. Covers hosting staging, plugins, WP-CLI, git workflows, and CI/CD with GitHub Actions.

A comprehensive WordPress security hardening guide for 2026 covering server configuration, authentication with Passkeys, WAF setup, CSP headers, database protection, headless security, and a 25-point audit checklist.
wordpress

WordPress Security Hardening 2026: The Complete Guide From Server to Application

A comprehensive WordPress security hardening guide for 2026 covering server configuration, authentication with Passkeys, WAF setup, CSP headers, database protection, headless security, and a 25-point audit checklist.