How I Migrated a WordPress Site from Localhost to the Cloud (Step by Step)
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:
| Metric | Localhost + Ngrok | Railway |
|---|---|---|
| GTmetrix Grade | A | A |
| Performance Score | 100% | 99% |
| Structure Score | 97% | 97% |
| Largest Contentful Paint | 688ms | 565ms |
| Total Blocking Time | 0ms | 18ms |
| HTTPS | No | Yes |
| Uptime 24/7 | No | Yes |
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.