Back to journal

How I Migrated a WordPress Site from Localhost to the Cloud (Step by Step)

May 12, 20269 min read

In this post, I will walk you through the full process of migrating a WordPress site from a local XAMPP environment to Railway, a cloud platform-as-a-service. Since I don’t have two separate hosting providers to work with, I simulated a real-world hosting migration using localhost as the “origin server” and Railway as the “destination server” which is actually a very common scenario when moving from a local dev environment to production.

Why Railway? It’s free for 30 days, supports MySQL out of the box, handles HTTPS automatically, and deploys straight from GitHub in minutes. No manual server configuration needed.

Here’s the full tech stack I used:

  • XAMPP (local web server)
  • WordPress (CMS)
  • Railway (cloud platform)
  • Docker (containerization)
  • GitHub (version control & CI/CD)
  • Ngrok (expose localhost to public internet)
  • GTmetrix (performance benchmarking)
  • Linux Ubuntu (OS)

And here’s the high-level overview of the project:

  • Setup & Preparation
  • Backup & Export
  • Deploy to Railway
  • Domain, SSL & Verification

Setup & Preparation

Setting Up XAMPP

First, make sure XAMPP is installed. If not, grab it from apachefriends.org for your OS. Once installed, start Apache and MySQL either from the XAMPP control panel or via terminal:

sudo /opt/lampp/lampp start

Open http://localhost in your browser. If the XAMPP welcome page shows up, you’re good to go.

Installing WordPress

Download the latest WordPress from wordpress.org, then extract it to your XAMPP htdocs folder. On Ubuntu, that’s /opt/lampp/htdocs/. I named my folder “mysite”:

wget https://wordpress.org/latest.tar.gz -P ~/Downloads
sudo tar -xzf ~/Downloads/latest.tar.gz -C /opt/lampp/htdocs/
sudo mv /opt/lampp/htdocs/wordpress /opt/lampp/htdocs/mysite
sudo chown -R daemon:daemon /opt/lampp/htdocs/mysite

Next, create a database in phpMyAdmin (http://localhost/phpmyadmin) I named mine “wp_portfolio”. Then open http://localhost/mysite and follow the WordPress installation wizard:

  • Database Name: wp_portfolio
  • Username: root
  • Password: (leave blank for XAMPP default)
  • Database Host: localhost

Creating Demo Content

To make the migration meaningful and have something to benchmark, I created the following content:

  • 3 Pages: Welcome to My Digital Portfolio, About Me, Contact Me
  • 5 Posts: Why Choose Next.js?, Linux Distro Exploration, Directus API Guide, The Importance of UI/UX, Database Optimization
  • Uploaded several images via WP Admin > Media

This content will be used to compare load performance before and after migration.

Setting Up Railway

Head over to railway.app and sign up using your GitHub account. That’s all for now we’ll come back to Railway in Phase 3 Deploy to Railway.


Backup & Export

Exporting the Database

Export your WordPress database using mysqldump from the XAMPP binary:

/opt/lampp/bin/mysqldump -u root wp_portfolio > ~/mysite_backup_db.sql

Alternatively, you can do this through phpMyAdmin: select the database > Export tab > Format: SQL > Go.

Verify the backup file was created:

ls -lh ~/mysite_backup_db.sql

Backing Up WordPress Files

Zip the entire WordPress folder as a secondary backup:

sudo zip -r ~/mysite_backup_files.zip /opt/lampp/htdocs/mysite/

Noting Your Database Credentials

Before moving on, check your current wp-config.php to note the local credentials. These will be replaced with Railway credentials later:

sudo cat /opt/lampp/htdocs/mysite/wp-config.php | grep "DB_"

Expected output:

define( 'DB_NAME', 'wp_portfolio' );
define( 'DB_USER', 'root' );
define( 'DB_PASSWORD', '' );
define( 'DB_HOST', 'localhost' );

Benchmarking Localhost Performance (Before Migration)

To get a baseline performance score, we need to expose localhost to the public internet using Ngrok, since GTmetrix can’t access your local machine directly.

Install Ngrok:

curl -sSL https://ngrok-agent.s3.amazonaws.com/ngrok.asc \
  | sudo tee /etc/apt/trusted.gpg.d/ngrok.asc >/dev/null

echo "deb https://ngrok-agent.s3.amazonaws.com buster main" \
  | sudo tee /etc/apt/sources.list.d/ngrok.list

sudo apt update && sudo apt install ngrok

Sign up at ngrok.com to get your authtoken, then authenticate:

ngrok config add-authtoken YOUR_TOKEN

Expose localhost port 80:

ngrok http 80

Ngrok will output a public URL like https://xxxx.ngrok-free.app. Paste that URL into gtmetrix.com and run a test. Screenshot the result this is your “before migration” benchmark.


Deploy to Railway

Setting Up Railway Services

Log in to Railway and create a new project: New Project > Empty Project. Click Add Service and select MySQL. Once MySQL is created, click on the service > Variables tab > expand the auto-generated variables and note down:

  • MYSQLHOST
  • MYSQLPORT
  • MYSQLUSER
  • MYSQLPASSWORD
  • MYSQLDATABASE

Creating the Dockerfile

Create a Dockerfile in your WordPress folder:

sudo nano /opt/lampp/htdocs/mysite/Dockerfile

Paste the following:

FROM php:8.1-apache

RUN a2enmod rewrite
RUN docker-php-ext-install mysqli pdo pdo_mysql

COPY --chown=www-data:www-data . /var/www/html/

RUN chmod +x /var/www/html/docker-entrypoint-custom.sh

ENTRYPOINT ["/var/www/html/docker-entrypoint-custom.sh"]

This uses the official PHP 8.1 Apache image, enables mod_rewrite for WordPress permalinks, installs MySQL extensions, and runs our custom entrypoint script on startup.

Creating the Entrypoint Script

This is an important part. Since wp-config.php contains sensitive credentials, we don’t want to commit it to GitHub. Instead, we generate it dynamically at container startup using Railway environment variables.

Create the entrypoint script:

sudo nano /opt/lampp/htdocs/mysite/docker-entrypoint-custom.sh

Paste the following:

#!/bin/bash

# Configure Apache MPM
a2dismod mpm_event mpm_worker mpm_prefork 2>/dev/null || true
a2enmod mpm_prefork

# Generate wp-config.php from environment variables if it doesn't exist
if [ ! -f /var/www/html/wp-config.php ]; then
    cat > /var/www/html/wp-config.php <<EOF
<?php
define('DB_NAME',     '${WORDPRESS_DB_NAME}');
define('DB_USER',     '${WORDPRESS_DB_USER}');
define('DB_PASSWORD', '${WORDPRESS_DB_PASSWORD}');
define('DB_HOST',     '${WORDPRESS_DB_HOST}');
define('DB_CHARSET',  'utf8');
define('DB_COLLATE',  '');

define('AUTH_KEY',         'put your unique phrase here');
define('SECURE_AUTH_KEY',  'put your unique phrase here');
define('LOGGED_IN_KEY',    'put your unique phrase here');
define('NONCE_KEY',        'put your unique phrase here');
define('AUTH_SALT',        'put your unique phrase here');
define('SECURE_AUTH_SALT', 'put your unique phrase here');
define('LOGGED_IN_SALT',   'put your unique phrase here');
define('NONCE_SALT',       'put your unique phrase here');

define('FORCE_SSL_ADMIN', true);
if (strpos(\$_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '', 'https') !== false) {
    \$_SERVER['HTTPS'] = 'on';
}

\$table_prefix = 'wp_';
define('WP_DEBUG', false);

if ( !defined('ABSPATH') )
    define('ABSPATH', dirname(__FILE__) . '/');

require_once(ABSPATH . 'wp-settings.php');
EOF
    chown www-data:www-data /var/www/html/wp-config.php
    echo "wp-config.php generated successfully"
fi

exec apache2-foreground

Creating .gitignore

We intentionally exclude wp-config.php and uploads from version control:

sudo nano /opt/lampp/htdocs/mysite/.gitignore
wp-config.php
wp-content/cache/
wp-content/uploads/
.env

Pushing to GitHub

Initialize a Git repo and push to GitHub. Create a new repository on github.com first, then:

cd /opt/lampp/htdocs/mysite
sudo git init
sudo git add .
sudo git commit -m "Initial WordPress migration"
sudo git remote add origin https://github.com/yourusername/mysite.git
sudo git push -u origin master

Back in Railway: Add Service > GitHub Repo > select your repository. Railway will automatically build and deploy using your Dockerfile.

Setting Environment Variables

In Railway dashboard, click on the WordPress service > Variables tab > add these four variables with the values from your MySQL service:

  • WORDPRESS_DB_HOST : Internal Railway MySQL hostname
  • WORDPRESS_DB_NAME : MySQL database name (railway)
  • WORDPRESS_DB_USER : MySQL username
  • WORDPRESS_DB_PASSWORD : MySQL password

Importing the Database

One important gotcha: Railway MySQL is not directly accessible from your local terminal via IP and port. Attempting a direct connection will result in this error:

ERROR 2013 (HY000): Lost connection to MySQL server at
'reading initial communication packet', system error: 0

The solution is to use the Railway CLI, which creates a secure tunnel to the internal database. Install it and log in:

npm install -g @railway/cli
railway login

Link your local project folder to the Railway project:

cd /opt/lampp/htdocs/mysite
railway link

Select your workspace, project, environment, and MySQL service when prompted. Then open a tunnel directly into the MySQL shell:

railway connect mysql

Railway CLI will open a MySQL shell without needing to enter credentials manually. Once inside, import your backup:

SOURCE ~/mysite_backup_db.sql

Wait until all queries show “Query OK”. Then verify the content was imported correctly:

USE railway;
SHOW TABLES;
SELECT ID, post_title, post_type FROM wp_posts WHERE post_status = 'publish';

Updating the Site URL

After import, the database still contains the old localhost URLs. Update them to your Railway domain:

UPDATE wp_options
SET option_value = 'https://yoursite.up.railway.app'
WHERE option_name IN ('siteurl', 'home');

UPDATE wp_posts
SET post_content = REPLACE(post_content,
  'http://localhost/mysite',
  'https://yoursite.up.railway.app');

UPDATE wp_posts
SET guid = REPLACE(guid,
  'http://localhost/mysite',
  'https://yoursite.up.railway.app');

Type exit to close the MySQL shell.


Domain, SSL & Verification

Generating a Railway Domain

In the Railway dashboard, click on your WordPress service > Settings tab > Networking > Generate Domain. Railway will assign a URL like yoursite.up.railway.app with HTTPS automatically provisioned. No manual SSL configuration needed.

Verifying the Site

Open your Railway URL in the browser. The site should load with all pages and posts intact. However, if the site loads but images are missing, that’s because wp-content/uploads/ was excluded in .gitignore and never pushed to GitHub.

Fix this with a one-time force push of the uploads folder:

cd /opt/lampp/htdocs/mysite
sudo git add -f wp-content/uploads/
sudo git commit -m "Add media uploads for migration"
sudo git push

Wait for Railway to finish redeploying, then hard refresh the browser (Ctrl+Shift+R). Images should now appear.

Checking for Mixed Content Issues

Press F12 in your browser > Console tab. Look for any errors starting with “Mixed Content: …”. If the console is clean, all assets are being served over HTTPS correctly. If you do see mixed content errors, make sure the FORCE_SSL_ADMIN and HTTP_X_FORWARDED_PROTO lines in your entrypoint script are in place — they handle HTTPS detection behind Railway’s reverse proxy.

Benchmarking Performance After Migration

Head back to gtmetrix.com, enter your Railway URL, and run a test. Here’s how my results compared before and after migration:

MetricLocalhost + NgrokRailway
GTmetrix GradeAA
Performance Score100%99%
Structure Score97%97%
Largest Contentful Paint688ms565ms
Total Blocking Time0ms18ms
HTTPSNoYes
Uptime 24/7NoYes

Note: the localhost benchmark was technically measured against Ngrok’s browser interstitial warning page rather than WordPress directly, so the numbers aren’t a perfect apples-to-apples comparison. The Railway result, however, reflects real production WordPress performance.


Wrapping Up

And that’s a wrap! The WordPress site is now fully migrated and running live on Railway at https://mysite-production-34dc.up.railway.app with automatic HTTPS, a cloud MySQL database, and GitHub-powered auto-deploy on every push.

A few things I found interesting about this project:

First, the wp-config.php approach. Generating it dynamically from environment variables at container startup is actually the right way to handle credentials in a containerized environment. Nothing sensitive ever touches your Git history.

Second, the Railway CLI tunnel was a genuinely elegant solution to the database access problem. Instead of exposing the MySQL port to the public internet, Railway keeps it internal and lets the CLI proxy your connection securely.

Third, the one-time force push for media uploads is a migration-only workaround. In a real production setup, you’d want to use a persistent volume or an object storage service like AWS S3 for media files, since Railway’s filesystem resets on every redeploy.

Hope this was useful feel free to reach out if you run into any issues replicating this setup.