Hardening WordPress Security Using .htaccess

The default .htaccess file created by WordPress during the installation process on Apache contains only the basic directives needed for WordPress to function. Additional directives may be included in .htaccess to further secure and harden WordPress from common attacks. Many of the below suggestions are not specific to WordPress so they may be used to increase the security posture of any site served by Apache. Other directives are also included below to reduce bandwidth consumption and improve site performance through HTTP compression and browser caching.

The following examples and suggestions assume a single WordPress installation in the document root (not installed in a sub-directory). It also assumes the typical user with a modern browser. If you have a large userbase of early generation browsers, then please adjust accordingly. Remember, defense in depth, so combine these methods with others to create a balance of security and usability.

Prevent Directory Listings

The first directive in the file disables directory listings. If a user attempts to browse a directory, Apache won’t serve a listing of all subdirectories or files at that location. This limits exposure of the underlying file system structure and content.

Options All -Indexes

Hide Apache Version

The next directive instructs Apache to not display its version information on server generated error pages, listings, etc. If a bad actor is scanning for a version of Apache with a known vulnerability, this makes it slightly more difficult to fingerprint.

ServerSignature Off

Limit Allowed HTTP Request Methods

Allowed request methods are limited to POST, GET, HEAD, and OPTIONS. If the site is using other methods, then adjust the list accordingly. By leaving unused request methods open, it increases the attack surface and exposes the site to potential attacks. Bad actors attempt to exploit vulnerabilities in the server configuration by sending crafted requests or malformed request methods.

<LimitExcept POST GET HEAD OPTIONS>
  Order allow,deny
  Require all denied
</LimitExcept>

Block IP Addresses and Ranges

Known IP addresses of spammers or other bad actors are blocked from accessing the site in the following directive. If you monitor the server log files for any period of time, you will find certain IP addresses are constantly scanning for vulnerabilities. Specific addresses or ranges can be blocked by adjusting the RewriteCond lines. Replace the addresses in the example with the appropriate address numbers or ranges. If a user with a listed IP address attempts to connect to the site, the request fails and no further directives are evaluated. This directive can quickly grow too large to realistically maintain. Consider leveraging stronger firewall solutions for easier and automated blocking.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REMOTE_ADDR} ^10\.0\.0\.1 [OR]
  RewriteCond %{REMOTE_ADDR} ^10\.32\.64\.250
  RewriteRule ^ - [NC,F,L]
</IfModule>

Block Referrer Links

If the site receives referral traffic from either undesirable sites or entire generic top-level domains, this directive prevents incoming traffic from those sites when the supplied referrer information matches the RewriteCond lines. While this does not prevent other sites from simply including a link on their site or copying content, it does block incoming traffic from those sites. In the example, traffic from all .cc, .eu. and .ru top-level domains are blocked as well as traffic from example1.com and example2.com. When the referrer data matches either condition, the request fails and no further directives are evaluated. Adjust the conditions to meet the specific needs of your site as this directive is rather restrictive.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_REFERER} ^http(?:s)?://(?:.*)?\.(?:cc|eu|ru)(?:/.*)?$ [NC,OR]
  RewriteCond %{HTTP_REFERER} ^http(?:s)?://(?:.*\.)?(?:example1.com|example2.com)(?:/.*)?$ [NC]
  RewriteRule ^ - [NC,F,L]
</IfModule>

Block HTTP/1.0 Requests

The following directive blocks any user attempting to access the site using HTTP/1.0. I chose to block this protocol because it is outdated and I assume any connection attempt using this protocol is likely not coming from a typical user. If a user attempts to connect to the site using HTTP/1.0, the request fails and no further directives are evaluated.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{SERVER_PROTOCOL} ^HTTP/1\.0$
  RewriteRule ^ - [NC,F,L]
</IfModule>

Block Requests with Empty HTTP_USER_AGENT String

This directive assumes that all typical users have an HTTP_USER_AGENT string which signals to the server the product name / browser and version used to access the site. If the user-agent string is blank or whitespace or dashes, then the request fails and no further directives are evaluated.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_USER_AGENT} ^(?:\s|-)*$
  RewriteRule ^ - [NC,F,L]
</IfModule>

Block Access to WordPress Configuration Files and XML-RPC

If a user attempts to access a file listed in the directive, the request fails. The files listed here are common configuration files or log files for WordPress which should not be exposed to general users for security reasons. The XML-RPC endpoint is commonly used by bad actors for DDoS and brute force attacks since it provides programmatic remote access to a site.

<FilesMatch "wp-config\.php|xmlrpc\.php|error_log|readme\.html|license\.txt|wp-config-sample\.php">
  Order allow,deny
  Require all denied
</FilesMatch>

Restrict WordPress Login to Known IP Addresses

This directive restricts access to wp-login to specific IP addresses or ranges. If an installation only has a handful of registered users, this directive limits login attempts to those known user IP addresses. This reduces a bad actor’s ability to brute force login attempts. Ensure the allowed IP list includes the site’s public IP address otherwise WordPress will indicate an issue in Site Health because it is unable to perform loopback requests. Replace the addresses in the example with the appropriate address numbers or ranges. Please keep in mind that this is very restrictive and you will not be able to login if you are accessing the site from a different IP address, e.g. mobile.

<FilesMatch "wp-login\.php">
  Order deny,allow
  deny from all
  allow from 172.16.0.1
  allow from 172.30.254.1
</FilesMatch>

Block Hotlinking

The following directive prevents hotlinking of images and common files. Hotlinking occurs when another site links directly to an image/file on your site instead of hosting the image/file itself. If the other site has high traffic, it will consume your site’s bandwidth and potentially degrade server performance. It may also have a cost impact if your bandwidth threshold is exceeded. Adjust the RewriteCond to your domain(s). Again, if the request is not coming from a specified domain, then the request fails and no further directives are evaluated.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_REFERER} !^$
  RewriteCond %{HTTP_REFERER} !^http(?:s)?://(?:.*\.)?dalesandro\.net(?:/.*)?$ [NC]
  RewriteRule \.(?:jpe?g|gif|png|svg|webp|zip|rar|pdf)$ - [NC,F,L]
</IfModule>

Block Direct Access to /wp-includes

This section restricts direct access to the wp-includes directory which contains core WordPress code and scripts. Public access to this directory is not required or intended for a WordPress site to function so direct access should be restricted.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^wp-admin/includes/ - [NC,F,L]
  RewriteRule !^wp-includes/ - [S=3]
  RewriteRule ^wp-includes/[^/]+\.php$ - [NC,F,L]
  RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [NC,F,L]
  RewriteRule ^wp-includes/theme-compat/ - [NC,F,L]
</IfModule>

Block WordPress Author Scans

Author scans are performed to enumerate usernames for WordPress based sites. Any usernames identified through these scans can be used in brute force attempts to access the admin section of the site. If you browse to a WordPress site and include /author=1 (or 2, 3, 4, etc.), WordPress will return author details for that particular ID. This directive blocks these requests.

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteCond %{QUERY_STRING} (author=\d+) [NC]
  RewriteRule ^ - [NC,F,L]
</IfModule>

Return 410 Gone Response for Deleted Posts/Pages

While not security focused, this is an example of how to handle removed posts/pages. The “G” flag in the RewriteRule returns a 410 Gone response status code which informs browsers and bots that the resource has been intentionally removed.


<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_URI} ^/post-permalink(?:/.*)?$ [OR,NC]
  RewriteCond %{REQUEST_URI} ^/2021/02/28/another-post-permalink(?:/.*)?$ [OR,NC]
  RewriteCond %{REQUEST_URI} ^/yet-another-post-permalink(?:/.*)?$ [NC]
  RewriteRule ^ - [NC,G,L]
</IfModule>

Permanent Redirects (301) for Renamed Permalinks

Again, not a security focused example, but this example demonstrates how to handle changed permalinks. If the permalink for a specific post/page has changed, these directives return a 301 Moved Permanently response status code. This provides browsers/bots with the new resource location for the redirect and avoids a 404 Not Found response status code while informing bots to update its index. Adjust the RewriteCond to your domain(s).

RedirectMatch 301 ^/post-permalink(?:/.*)?$ https://www.dalesandro.net/updated-post-permalink/
RedirectMatch 301 ^/another-post-permalink(?:/.*)?$ https://www.dalesandro.net/replacement-permalink/

RedirectMatch may also be used for more significant and sitewide changes to the permalink structure such as changing from the “Day and name” permalink structure which includes the year, month, and day followed by the post name, e.g. /2013/07/06/sample-post/, to the “Post name” permalink structure which only includes the post name, e.g. /sample-post/. Assuming the site has been previously indexed under the old permalink structure, the search results will return the old “Day and name” URLs which will generate 404 Not Found response status codes.

This directive returns the more appropriate response code of 301 (moved permanently) and redirects users to the appropriate URL in the new permalink structure (assuming a change from “Day and name” to “Post name”).

RedirectMatch 301 ^/([0-9]{4})/([0-9]{2})/([0-9]{2})/(?:.*)$ https://www.dalesandro.net/$4

Reduce Automated WordPress Comment Spam

WordPress sites that allow comment submissions will receive comment spam. These comments are usually submitted through automated bots. In many cases, these bots don’t actually submit the comment through the form displayed on a post/page. Instead, the comment is submitted programmatically and directly through wp-comments-post.php. This directive assumes that any POST submissions to wp-comments-post.php are spam where the referrer is not your own domain or the user-agent string is empty. When these conditions are met, then the request fails and no further directives are evaluated. Adjust the RewriteCond to your domain(s).

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_METHOD} POST [NC]
  RewriteCond %{REQUEST_URI} wp-comments-post\.php [NC]
  RewriteCond %{HTTP_REFERER} !^http(s)?://(.*\.)?dalesandro\.net(/.*)?$ [OR,NC]
  RewriteCond %{HTTP_USER_AGENT} ^(?:\s|-)*$
  RewriteRule ^ - [NC,F,L]
</IfModule>

Add Secure Headers

The following section adds secure headers to HTTP responses. In modern browsers, these headers help prevent some vulnerabilities such a cross-site scripting and clickjacking. These should be adjusted for your particular site. For more information, review OWASP Secure Headers Project.

<IfModule mod_headers.c>
  Header set X-XSS-Protection "1; mode=block"
  Header set X-Frame-Options "DENY"
  Header set X-Content-Type-Options nosniff
  Header set Referrer-Policy "same-origin"
</IfModule>

Add HTTP Compression

With the exception of already compressed file types, e.g. image files, zip/rar, pdf, the following directive instructs Apache to compress any other served files. While this isn’t security related, it reduces bandwidth consumption and improves performance.

<IfModule mod_deflate.c>
  SetOutputFilter DEFLATE
  <IfModule mod_setenvif.c>
    SetEnvIfNoCase Request_URI \.(?:jpe?g|gif|png|svg|webp|zip|rar|pdf)$ no-gzip dont-vary
  </IfModule>
</IfModule>

Add Browser Caching Directives

In addition to HTTP compression above, the following directive instructs browser how long to cache certain file types. Obviously, this will reduce bandwidth consumption and improve performance since browsers won’t request the same file over and over with each post/page view. These file types are assumed to be mostly static and remain unchanged for long periods of time. The cache time should be adjusted to meet your own requirements and file rules should be added/removed as needed.

<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresDefault "access plus 1 month"
  ExpiresByType text/cache-manifest "access plus 0 seconds"
  ExpiresByType text/html "access plus 0 seconds"
  ExpiresByType text/xml "access plus 0 seconds"
  ExpiresByType application/xml "access plus 0 seconds"
  ExpiresByType application/json "access plus 0 seconds"
  ExpiresByType application/rss+xml "access plus 1 hour"
  ExpiresByType application/atom+xml "access plus 1 hour"
  ExpiresByType image/x-icon "access plus 1 year"
  ExpiresByType image/gif "access plus 1 year"
  ExpiresByType image/png "access plus 1 year"
  ExpiresByType image/jpg "access plus 1 year"
  ExpiresByType image/jpeg "access plus 1 year"
  ExpiresByType image/webp "access plus 1 year"
  ExpiresByType image/svg+xml "access plus 1 year"
  ExpiresByType image/vnd.microsoft.icon "access plus 1 year"
  ExpiresByType video/ogg "access plus 1 year"
  ExpiresByType audio/ogg "access plus 1 year"
  ExpiresByType video/mp4 "access plus 1 year"
  ExpiresByType video/webm "access plus 1 year"
  ExpiresByType video/mpeg "access plus 1 year"
  ExpiresByType text/x-component "access plus 1 year"
  ExpiresByType font/ttf "access plus 1 year"
  ExpiresByType font/otf "access plus 1 year"
  ExpiresByType font/woff "access plus 1 year"
  ExpiresByType font/woff2 "access plus 1 year"
  ExpiresByType application/font-woff "access plus 1 year"
  ExpiresByType application/x-font-ttf "access plus 1 year"
  ExpiresByType font/opentype "access plus 1 year"
  ExpiresByType application/vnd.ms-fontobject "access plus 1 year"
  ExpiresByType text/css "access plus 1 month"
  ExpiresByType text/javascript "access plus 1 month"
  ExpiresByType text/x-javascript "access plust 1 month"
  ExpiresByType application/javascript "access plus 1 month"
  ExpiresByType application/x-javascript "access plus 1 month"
  ExpiresByType application/pdf "access plus 1 month"
  ExpiresByType application/zip "access plus 1 month"
  <IfModule mod_headers.c>
    Header append Cache-Control "public"
  </IfModule>
</IfModule>

Everything All Together

Finally, this is the complete .htaccess file containing all of the directives reviewed above.

Options All -Indexes

ServerSignature Off

<LimitExcept POST GET HEAD OPTIONS>
  Order allow,deny
  Require all denied
</LimitExcept>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REMOTE_ADDR} ^10\.0\.0\.1 [OR]
  RewriteCond %{REMOTE_ADDR} ^10\.32\.64\.250
  RewriteRule ^ - [NC,F,L]
</IfModule>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_REFERER} ^http(?:s)?://(?:.*)?\.(?:cc|eu|ru)(?:/.*)?$ [NC,OR]
  RewriteCond %{HTTP_REFERER} ^http(?:s)?://(?:.*\.)?(?:example1.com|example2.com)(?:/.*)?$ [NC]
  RewriteRule ^ - [NC,F,L]
</IfModule>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{SERVER_PROTOCOL} ^HTTP/1\.0$
  RewriteRule ^ - [NC,F,L]
</IfModule>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_USER_AGENT} ^(?:\s|-)*$
  RewriteRule ^ - [NC,F,L]
</IfModule>

<FilesMatch "wp-config\.php|xmlrpc\.php|error_log|readme\.html|license\.txt|wp-config-sample\.php">
  Order allow,deny
  Require all denied
</FilesMatch>

<FilesMatch "wp-login\.php">
  Order deny,allow
  deny from all
  allow from 172.16.0.1
  allow from 172.30.254.1
</FilesMatch>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{HTTP_REFERER} !^$
  RewriteCond %{HTTP_REFERER} !^http(?:s)?://(?:.*\.)?dalesandro\.net(?:/.*)?$ [NC]
  RewriteRule \.(?:jpe?g|gif|png|svg|webp|zip|rar|pdf)$ - [NC,F,L]
</IfModule>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^wp-admin/includes/ - [NC,F,L]
  RewriteRule !^wp-includes/ - [S=3]
  RewriteRule ^wp-includes/[^/]+\.php$ - [NC,F,L]
  RewriteRule ^wp-includes/js/tinymce/langs/.+\.php - [NC,F,L]
  RewriteRule ^wp-includes/theme-compat/ - [NC,F,L]
</IfModule>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteCond %{QUERY_STRING} (author=\d+) [NC]
  RewriteRule ^ - [NC,F,L]
</IfModule>

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_URI} ^/post-permalink(?:/.*)?$ [OR,NC]
  RewriteCond %{REQUEST_URI} ^/2021/02/28/another-post-permalink(?:/.*)?$ [OR,NC]
  RewriteCond %{REQUEST_URI} ^/yet-another-post-permalink(?:/.*)?$ [NC]
  RewriteRule ^ - [NC,G,L]
</IfModule>

RedirectMatch 301 ^/post-permalink(?:/.*)?$ https://www.dalesandro.net/updated-post-permalink/
RedirectMatch 301 ^/another-post-permalink(?:/.*)?$ https://www.dalesandro.net/replacement-permalink/

RedirectMatch 301 ^/([0-9]{4})/([0-9]{2})/([0-9]{2})/(?:.*)$ https://www.dalesandro.net/$4

<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteCond %{REQUEST_METHOD} POST [NC]
  RewriteCond %{REQUEST_URI} wp-comments-post\.php [NC]
  RewriteCond %{HTTP_REFERER} !^http(s)?://(.*\.)?dalesandro\.net(/.*)?$ [OR,NC]
  RewriteCond %{HTTP_USER_AGENT} ^(?:\s|-)*$
  RewriteRule ^ - [NC,F,L]
</IfModule>

<IfModule mod_headers.c>
  Header set X-XSS-Protection "1; mode=block"
  Header set X-Frame-Options "DENY"
  Header set X-Content-Type-Options nosniff
  Header set Referrer-Policy "same-origin"
</IfModule>

<IfModule mod_deflate.c>
  SetOutputFilter DEFLATE
  <IfModule mod_setenvif.c>
    SetEnvIfNoCase Request_URI \.(?:jpe?g|gif|png|svg|webp|zip|rar|pdf)$ no-gzip dont-vary
  </IfModule>
</IfModule>

<IfModule mod_expires.c>
  ExpiresActive On
  ExpiresDefault "access plus 1 month"
  ExpiresByType text/cache-manifest "access plus 0 seconds"
  ExpiresByType text/html "access plus 0 seconds"
  ExpiresByType text/xml "access plus 0 seconds"
  ExpiresByType application/xml "access plus 0 seconds"
  ExpiresByType application/json "access plus 0 seconds"
  ExpiresByType application/rss+xml "access plus 1 hour"
  ExpiresByType application/atom+xml "access plus 1 hour"
  ExpiresByType image/x-icon "access plus 1 year"
  ExpiresByType image/gif "access plus 1 year"
  ExpiresByType image/png "access plus 1 year"
  ExpiresByType image/jpg "access plus 1 year"
  ExpiresByType image/jpeg "access plus 1 year"
  ExpiresByType image/webp "access plus 1 year"
  ExpiresByType image/svg+xml "access plus 1 year"
  ExpiresByType image/vnd.microsoft.icon "access plus 1 year"
  ExpiresByType video/ogg "access plus 1 year"
  ExpiresByType audio/ogg "access plus 1 year"
  ExpiresByType video/mp4 "access plus 1 year"
  ExpiresByType video/webm "access plus 1 year"
  ExpiresByType video/mpeg "access plus 1 year"
  ExpiresByType text/x-component "access plus 1 year"
  ExpiresByType font/ttf "access plus 1 year"
  ExpiresByType font/otf "access plus 1 year"
  ExpiresByType font/woff "access plus 1 year"
  ExpiresByType font/woff2 "access plus 1 year"
  ExpiresByType application/font-woff "access plus 1 year"
  ExpiresByType application/x-font-ttf "access plus 1 year"
  ExpiresByType font/opentype "access plus 1 year"
  ExpiresByType application/vnd.ms-fontobject "access plus 1 year"
  ExpiresByType text/css "access plus 1 month"
  ExpiresByType text/javascript "access plus 1 month"
  ExpiresByType text/x-javascript "access plust 1 month"
  ExpiresByType application/javascript "access plus 1 month"
  ExpiresByType application/x-javascript "access plus 1 month"
  ExpiresByType application/pdf "access plus 1 month"
  ExpiresByType application/zip "access plus 1 month"
  <IfModule mod_headers.c>
    Header append Cache-Control "public"
  </IfModule>
</IfModule>

# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>

# END WordPress

How to Migrate an Existing WordPress Site to Amazon Lightsail LAMP

When I first started www.dalesandro.net, I found a relatively inexpensive shared hosting provider. The site remained in that environment for years. I never had any significant technical issues with the provider and the cost had remained flat. Keep in mind that this is an extremely low traffic site running a straightforward WordPress installation.

After receiving a renewal notice from the shared hosting provider with a substantial price increase, I decided to research other available options. Around the same time, I obtained the AWS Certified Cloud Practitioner (AWS CCP) certification where I learned of an offering called Amazon Lightsail. At its core, Lightsail allows you to instantiate one or more virtual private servers – a virtual machine, storage, bandwidth, and a static IP – for a (somewhat) predictable monthly price.

Within Amazon Lightsail, there are a number of options to establish an instance, e.g. selecting the operating system, application stack, and server size, etc. While you can select a Linux/Unix instance with WordPress already installed, I chose the LAMP (PHP 7) blueprint because I wanted to migrate an existing WordPress installation on my own and avoid any potential issues with a pre-installed package.

Step 1 – AWS Basics

If you don’t already have an AWS account, the first step is to create an account. Navigate to AWS and follow the on-screen instructions. I won’t walk through this part since it is a straightforward process to create an account. Also, I suggest researching AWS pricing to better understand how actions or selections impact the monthly bill. While Lightsail instances are considered fixed-price per month, if products/services are activated or the bandwidth allotment is exceeded, then the monthly bill will fluctuate. Depending on the cost trigger, the pricing change could be substantial.

Step 2 – Migration Preparation

In my opinion, this is the most difficult and time-consuming part of the process. Since I have an existing site with a shared hosting provider, I need to move the WordPress files as well as the database. The shared host uses cPanel, so there is a capability to download a copy of both the account directory as well as a dump of any MySQL databases. These backups are available in “Account Backups” within cPanel.

In my case, the challenge is that the directory structure on my shared host doesn’t match the structure that I want to use in Lightsail. After downloading both the account directory and MySQL backups, I make copies of the files in order to have a clean backup and a working copy. I then restructure the files manually, i.e. moving directories and changing paths in the database data. There are plugins available for WordPress to handle this search-and-replace activity, however, I am not familiar with them and I am comfortable making the changes myself. Also, since the site is mostly static, I work through the changes manually and at my own pace without worrying much about losing comment submissions in the meantime.

As far as the directory structure changes, I am moving from having WordPress installed within a sub-directory of my account to having WordPress installed in a primary directory. I have to update all sub-directory path references in files such as wp-config.php and .htaccess as well as the WordPress database tables. For my site, the majority of this search-and-replace activity is in the MySQL dump. Multiple references to both the sub-directory and the underlying file path on the shared host are in the options table as well as various plug-in tables. I find many path references included in serialized data within the MySQL dump so I take a risk updating it manually under the assumption it could be re-serialized by simply re-saving the settings once the site was up-and-running.

Take time and extra care as you work through this process.

At the end of this manual process, I create a new archive containing all of the WordPress files called wordpress_migration.tar.gz. Consolidating all of the necessary files makes the upload in Step 11 easier/faster. The archive is structured so that all of the WordPress files are under a parent directory named migration/public_html. I store the revised MySQL dump file named wrdprss.sql from the existing site into the migration directory (not the migration/public_html) in the archive.

wordpress_migration.tar.gz

migration/
 ├── wrdprss.sql  <-- MySQL dump file
 └── public_html/
     ├── index.php
     ├── license.txt
     ├── readme.html
     ├── wp-activate.php
     ├── wp-admin/
     ├── wp-blog-header.php
     ├── wp-comments-post.php
     ├── wp-config.php
     ├── wp-content/
     ├── wp-cron.php
     ├── wp-includes/    
     ├── wp-links-opml.php
     ├── wp-load.php
     ├── wp-login.php
     ├── wp-mail.php
     ├── wp-settings.php
     ├── wp-signup.php
     ├── wp-trackback.php
     └── xmlrpc.php

Step 3 – Testing the Migration Files

For some of the available blueprints, Amazon Lightsail uses stack installers/images developed by Bitnami. I download the Bitnami LAMP Virtual Machine (VM) and run it locally using VirtualBox. This allows me to test my migration steps as well as my migration archive/files before incurring costs on AWS. Follow Steps 7 to Step 15 as a local test prior to performing the migration on the production Lightsail instance.

Step 4 – Create an Amazon Lightsail Instance

Login to the AWS Management Console and search for Lightsail. Follow the on-screen instructions to create a new Lightsail instance. I instantiate a Lightsail instance using the LAMP blueprint (Linux / Apache / MySQL / PHP).

Step 5 – Create and Attach a Static IP

Follow these instructions to create a static IP and attach it to the Lightsail instance.

Step 6 – Use SSH to Connect to the Instance

Once the Lightsail instance running, I need to connect to it in order to start performing the migration. A connection to the instance can be made directly through the browser using the “Connect to your instance” option (follow these instructions). The other option is to use PuTTY and a private key. I prefer to use PuTTY since it feels more stable than the browser based connection. Follow these instructions to make a PuTTY-based connection to your instance.

Step 7 – Update and Upgrade the Instance

At the command prompt, I execute the following command to update and upgrade the instance packages/software. This is a maintenance step that should be performed regularly as this will apply any available patches. With Lightsail, you are responsible for the ongoing maintenance activities associated with your server.

sudo apt update && sudo apt upgrade

Step 8 – Create a New DocumentRoot Directory

The DocumentRoot directory is where the WordPress application files will eventually be stored and served. While the Bitnami installation of Apache defaults to /opt/bitnami/apache/htdocs, I prefer to create a separate directory structure outside of the server path. You may use the default path or change this folder structure to match your own requirements.

The mkdir -p option creates each parent/child directory in the specified path instead of executing individual mkdir commands for each sub-directory. Replace example-com as appropriate.

sudo mkdir -p /www/example-com/public_html

Step 9 – Create Apache vhost Files

vhost files are used to configure Apache to serve files from the new DocumentRoot. For this step, I’ve modified the instructions from the Bitnami “Create a custom PHP application” guide. Depending on which Bitnami image was used to create the Lightsail instance, the following command returns either Approach A or Approach B.

test ! -f "/opt/bitnami/common/bin/openssl" && echo "Approach A: Using system packages." || echo "Approach B: Self-contained installation."

In my example, Approach A is returned so the following command sequence is appropriate for that path. These commands create copies of the existing vhost sample files for both http and https. vim is then used to edit both files to reflect the new DocumentRoot from Step 8. Replace/rename example-com as appropriate.

cd /opt/bitnami/apache2/conf/vhosts
cp sample-vhost.conf.disabled example-com-vhost.conf.disabled
vim example-com-vhost.conf.disabled
mv example-com-vhost.conf.disabled example-com-vhost.conf

Modify the DocumentRoot and Directory sections to /www/example-com/public_html following the sample file format.

If you’re not familiar with editing in vim, please spend a few minutes learning the basic keyboard commands. The file opens in command mode. To make changes to the file, press i to start editing. After completing the revisions, press escape to return to command mode. Once satisfied with the changes, enter :w to write (save) the file. To exit vim, enter :q in command mode.

Make the same changes to the https version of the .conf file.

cp sample-https-vhost.conf.disabled example-com-https-vhost.conf.disabled
vim example-com-https-vhost.conf.disabled
mv example-com-https-vhost.conf.disabled example-com-https-vhost.conf

Restart Apache.

sudo /opt/bitnami/ctlscript.sh restart apache

Step 10 – Enable and Start SSH (Optional)

This step may not be necessary. On the actual Lightsail instance, SSH is already enabled and running so these next commands are not needed. However, on my local VirtualBox instance of the Bitnami LAMP VM, SSH is not running.

To verify ssh is enabled and running, execute the following command. sshd is listed in the output if it is running

ps aux | grep sshd

If it is enabled and running, execute the following command to find the port number SSH is listening on.

netstat -plant | grep :22

If SSH is not running, execute the following commands to enable and start SSH. These commands follow the Bitnami “Enable or Disable the SSH Server” guide.

sudo rm -f /etc/ssh/sshd_not_to_be_run
sudo systemctl enable ssh
sudo systemctl start ssh

Step 11 – Use Putty SFTP to Upload Migration Archive

I use PuTTY PSFTP to transfer the wordpress_migration.tar.gz file (created in Step 2) from my local machine to the Lightsail instance. Replace <static ip> with the actual static IP address and replace the path and filename as appropriate. This sequence of commands transfers the file to the Lightsail user account’s home directory on the instance.

open <static ip>
put c:\local\path\to\wordpress_migration.tar.gz
exit

Step 12 – Stop and Disable SSH (Optional)

Like Step 10, this step may not be necessary. If SSH was not running in Step 10 and you want revert to that state, e.g. stop/disable SSH, then execute the following commands. Otherwise skip this step.

sudo systemctl stop ssh
sudo systemctl disable ssh

Step 13 – Unpack the WordPress Files

In this step, the archive file containing all of the WordPress files from the existing site are unpacked and moved into the DocumentRoot defined in the vhost files. Recall in Step 2 that the parent directory migration/public_html is used in the archive structure. Since public_html is also used in the DocumentRoot directory structure, it makes the file movement easier. Adjust the following sequence of commands to match the directory structure defined in the migration file and DocumentRoot.

These commands unpack the migration archive into the home directory. In my example, this creates a folder structure of ~/migration/public_html containing all of the WordPress files from my existing site. Once the files are unpacked, the contents of ~/migration/public_html are moved to /www/example-com/public_html. Then the appropriate permissions are set to give Apache access to the directories and files and wp-config.php is secured. Apache is running as daemon in this example, but it may vary from instance to instance.

cd ~
tar -xzvf wordpress_migration.tar.gz
cd migration
sudo mv ./public_html /www/example-com
sudo chown -R daemon:daemon /www/example-com/public_html
sudo find /www/example-com/public_html -type d -exec chmod 755 {} \;
sudo find /www/example-com/public_html -type f -exec chmod 644 {} \;
sudo chmod 600 /www/example-com/public_html/wp-config.php

Step 14 – Create a New Database and Import Existing SQL Data

If the suggested directory structure from Step 2 was used, then the MySQL export file named wrdprss.sql from the existing site will be located in the migration directory when the archive is unpacked. The following sequence of commands creates a new database and imports the existing data. This sequence also assumes WordPress is not using root to access the database. Please make sure to use the user/password from the existing site to re-create the MySQL user/service account to access the database.

If a brand new user/password is created in this step, then wp-config.php must be updated to reflect the new user/service account. After the site is up-and-running, the service account password should be changed in case it was compromised in transit during the migration.

The output from the command “cat ~/bitnami_credentials” is the root password. Use the password when prompted after executing “mysql -u root -p”. Again, adjust the sequence to reflect the database name, user, password, and MySQL dump filename and path.

cat ~/bitnami_credentials
mysql -u root -p
CREATE DATABASE db_example_com_wp_prd;
CREATE USER 'u_example_com_wp_prd'@'localhost' IDENTIFIED BY '<password>';
GRANT ALL PRIVILEGES ON db_example_com_wp_prd.* TO 'u_example_com_wp_prd'@'localhost';
FLUSH PRIVILEGES;
USE db_example_com_wp_prd;
SOURCE ~/migration/wrdprss.sql
EXIT 

Step 15 – Access the Site Using Static IP Address

Navigate to the site using the AWS static IP address to verify the site is accessible and the migration was successful. The browser may display a warning if the site is redirected to https since new certificates have not yet been issued for this domain/IP combination.

Step 16 – Update DNS A Resource Record to AWS Static IP

Update the A resource record for the domain to use the AWS static IP. The DNS update needs to propagate prior to issuing new certificates in Step 17. Instructions to update the resource record are specific to your DNS provider.

Step 17 – Install SSL Certificates

If the site allows/forces https, then use the bncert-tool to create certificates through Let’s Encrypt. Execute the command and follow the on-screen instructions.

sudo /opt/bitnami/bncert-tool

Step 18 – Access the Site Using Domain Name

Navigate to the site using the domain name (instead of the AWS static IP address) to verify the site is up-and-running as expected.

Step 19 – Cleanup

If everything is working as expected, the final step is to remove the migration files from the home directory. This sequence deletes the migration archive as well as the unpacked migration directory.

cd ~
rm -f wordpress_migration.tar.gz
rm -rf migration

Further Reading

At this point, I hope your site has been migrated successfully to Amazon Lightsail. While the above steps will get a site up-and-running on Amazon Lightsail, it does not cover other aspects of managing your own server such as on-going maintenance and security.

For further reading, please consider:

Hardening WordPress Security Using .htaccess

Automate Amazon Lightsail Maintenance Activities Using Bash Script

Bash Script to Automatically Backup Amazon Lightsail WordPress Server to Google Drive

Automatically Email MySQL Database Backup

If you are in a hosting environment where you are responsible for your own database backups, this is a convenient way to have the server generate a backup file of all of your MySQL databases, compress the file, and email it to the specified address. I’ve added this to the local crontab so that it runs on a regular schedule. In order for it to work, you should have a MySQL user account created with the appropriate privileges in order to access your database.

In the crontab, create an entry, as follows, with your customization for both the path to your configuration file and the email address receiving the backup.

mysqldump --defaults-extra-file="/path/to/config/file/.mysqldump.cnf"
          --all-databases
          -ce
  | gzip
  | uuencode dbbackup.gz
  | /usr/sbin/sendmail my_email_address

In the .mysqldump.cnf file, provide the username and associated password that can access the database. Secure .mysqldump.cnf by setting the file permissions 0600 since that file contains your database password in plaintext.

[client]
user=my_database_username
password=my_database_user_password