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

The intent of this Bash script is to backup the WordPress installation directory, the MySQL database, and a handful of Apache/PHP/MySQL configuration files to Google Drive on a regular basis in an encrypted archive. While this script references paths/files specific to a Bitnami based Amazon Lightsail instance, it can be easily modified to any other Linux based environment. I fully understand that AWS has an automatic snapshot/backup capability, but I want to use Google Drive and I don’t want to incur any additional costs since AWS snapshots are not free.

Usage

If no options are specified when executing this script it will backup all MySQL databases accessible to the user specified in the .mysqldump.cnf file. No other files will be included in the backup. All files are packaged in a tar.gz file and encrypted using AES-256.

  • -f (full backup) includes both the MySQL dump as well as other files specified in the script.
  • -e is used to encrypt the backup archive. If this flag is set, then a password must be supplied in the .backup.cnf file.
  • -n x sets the number of files to retain on Google Drive in the upload directory (x is an integer value). The default is 30 files.
  • -d sets the parent folder where the backup file is uploaded on Google Drive. If the folder doesn’t exist, then the script will create it. The default folder name is “backup”.
  • -p sets the filename prefix of the backup file. The script will use the prefix followed by the date/time to set the full filename. The default filename prefix is “backup”.
  • -m is used to specify the database name to backup. The default is all databases.

Examples

This example uses all of the available options and will create a full encrypted backup of both MySQL and other files. The backup file is named backup-example-com (with a date/time suffix) and uploaded it to a folder named backup-full-example-com on Google Drive. If the specified folder contains more than 5 files, the script deletes any files beyond the 5 based on a filename sort.

./backup.sh -e -f -n 5 -d backup-full-example-com -p backup-example-com -m db_example_com_wp_prd

Installation

As a personal preference, I store user scripts and associated configuration files in a subdirectory under home named “scripts”.

The following command creates the “scripts” subdirectory.

mkdir -m 700 ~/scripts

Next, create a file named .mysqldump.cnf. This file will be used by the mysqldump command in the backup script to pass the MySQL database user and password to dump the WordPress database.

touch ~/scripts/.mysqldump.cnf
chmod 600 ~/scripts/.mysqldump.cnf
vim ~/scripts/.mysqldump.cnf

In the vim editor, add the database username and password with access to dump all of the required tables and data for a backup. Apostrophes surrounding the password are needed if any special characters are used in the actual password.

[mysqldump]
user=username
password='password'

The following commands create the .backup.cnf file which contains a secret key used by the script to encrypt the backup file.

touch ~/scripts/.backup.cnf
chmod 600 ~/scripts/.backup.cnf
vim ~/scripts/.backup.cnf

In the vim editor, add a secret key/password to the file.

pass=passsword

Now we’ll create the actual backup script. Please be sure to modify any referenced paths and constants to your own environment.

touch ~/scripts/backup.sh
chmod 700 ~/scripts/backup.sh
vim ~/scripts/backup.sh

The following is the full Bash script. Please modify any constants/paths for your own environment and requirements.

#!/bin/bash

getConfigVal() {
  echo `grep ${1} "${2}" | cut -d '=' -f 2`
}

dirname_temp_backup="automated_backup"
filename_prefix_backup="backup"
filename_suffix_backup="_$(date +'%Y_%m_%d_%H_%M')"
db_name="--all-databases"
gdrive_backup_dirname="backup"
num_files_to_retain=30
path_script="$(dirname "$(readlink -f "$0")")"
path_mysqldump="$(which mysqldump)"
path_gdrive="$(which drive)"
path_backup_cnf="${path_script}/.backup.cnf"
path_mysqldump_cnf="${path_script}/.mysqldump.cnf"
path_backup="${HOME}/${dirname_temp_backup}"

if [ -z "${path_script}" ] || [ -z "${path_mysqldump}" ] || [ -z "${path_gdrive}" ] || [ -z "${path_backup_cnf}" ] || [ -z "${path_mysqldump_cnf}" ] || [ -z "${path_backup}" ]
then
  echo "ERROR: One or more required path variable(s) are undefined or missing."
  exit 1
fi

while getopts efn:d:p:m: flag
do
  case "${flag}" in
    e)
      bool_encrypt_backup=1
      encryption_key=$(getConfigVal "pass" "${path_backup_cnf}")

      if [ -z "${encryption_key}" ]
      then
        echo "ERROR: Encryption key not found."
        exit 1
      fi
      ;;
    f)
      bool_full_backup=1
      ;;
    n)
      if ! [[ "${OPTARG}" =~ ^[0-9]+$ ]]
      then
        echo "WARNING: Number of backups to retain must be an integer. Reverting to default value (${num_files_to_retain})."
      else
        num_files_to_retain="${OPTARG}"
      fi
      ;;
    d)
      gdrive_backup_dirname="${OPTARG}"
      ;;
    p)
      filename_prefix_backup="${OPTARG}"
      ;;
    m)
      db_name="--databases ${OPTARG}"
      ;;
  esac
done

num_files_to_retain=$((num_files_to_retain+1))

sudo rm -rf "${path_backup}"
sudo rm "${HOME}/${filename_prefix_backup}"*.tar.gz

mkdir "${path_backup}"

if [ -n "${bool_full_backup}" ]
then
  find /www -type d -not -path '*/cache/*' -exec mkdir -p "${path_backup}"/{} \;
  find /www -type f -not -path '*/cache/*' -exec sudo cp '{}' "${path_backup}"/{} \;
  cp /opt/bitnami/apache2/conf/vhosts/example-com-https-vhost.conf "${path_backup}"
  cp /opt/bitnami/apache2/conf/vhosts/example-com-vhost.conf "${path_backup}"
  cp /opt/bitnami/apache/conf/httpd.conf "${path_backup}"
  cp /opt/bitnami/apache/conf/bitnami/bitnami-ssl.conf "${path_backup}"
  cp /opt/bitnami/php/etc/php.ini "${path_backup}"
  cp /opt/bitnami/mysql/conf/my.cnf "${path_backup}"
fi

"${path_mysqldump}" --defaults-extra-file="${path_mysqldump_cnf}" ${db_name} --no-tablespaces -ce > "${path_backup}/mysqldump.sql"

touch "${HOME}/${filename_prefix_backup}.tar.gz"

chmod 600 "${HOME}/${filename_prefix_backup}.tar.gz"

sudo tar -czf "${HOME}/${filename_prefix_backup}.tar.gz" -C "${path_backup}" .

if [ -n "${bool_encrypt_backup}" ]
then
  touch "${HOME}/${filename_prefix_backup}_enc.tar.gz"

  chmod 600 "${HOME}/${filename_prefix_backup}_enc.tar.gz"

  openssl enc -e -a -md sha512 -pbkdf2 -iter 100000 -salt -AES-256-CBC -pass "pass:${encryption_key}" -in "${HOME}/${filename_prefix_backup}.tar.gz" -out "${HOME}/${filename_prefix_backup}_enc.tar.gz"

  sudo rm "${HOME}/${filename_prefix_backup}.tar.gz"
  mv "${HOME}/${filename_prefix_backup}_enc.tar.gz" "${HOME}/${filename_prefix_backup}.tar.gz"
fi

mv "${HOME}/${filename_prefix_backup}.tar.gz" "${HOME}/${filename_prefix_backup}${filename_suffix_backup}.tar.gz"

folder_id=`"${path_gdrive}" list -m 1000 -n -q 'trashed = false' | grep -m 1 "${gdrive_backup_dirname}" | head -1 | cut -d ' ' -f1`

if [ -z "${folder_id}" ]
then
  "${path_gdrive}" folder -t "${gdrive_backup_dirname}"
  folder_id=`"${path_gdrive}" list -m 1000 -n -q 'trashed = false' | grep -m 1 "${gdrive_backup_dirname}" | head -1 | cut -d ' ' -f1`
fi

"${path_gdrive}" upload --file "${HOME}/${filename_prefix_backup}${filename_suffix_backup}.tar.gz" --parent "${folder_id}"

sudo rm -rf "${path_backup}"
sudo rm "${HOME}/${filename_prefix_backup}"*.tar.gz

expired_file_ids=`"${path_gdrive}" list -m 1000 -n -q "'${folder_id}' in parents and trashed = false and mimeType != 'application/vnd.google-apps.folder'" | sort -k 2r | tail -n +"${num_files_to_retain}" | cut -d ' ' -f1`

if [ -n "${expired_file_ids}" ]
then
  while read -r file_id; do
    "${path_gdrive}" delete --id "${file_id}"
  done <<< "${expired_file_ids}"
fi

In order for the script to communicate with Google Drive, it has a dependency on a freely available binary on GitHub. The following commands download the binary to a new subdirectory under home named “bin”.

cd ~
mkdir -m 700 ~/bin
cd ~/bin
wget -O drive https://drive.google.com/uc?id=0B3X9GlR6Embnb095MGxEYmJhY2c
chmod 500 ~/bin/drive

Execute the binary once it is downloaded and follow the directions to grant it permission to Google Drive.

~/bin/drive

Go to the following link in your browser:

Enter verification code:

Scheduling

Now that the script and supporting files are set up, the following commands are used to schedule the script in the user crontab. The following example schedules the script to run a full encrypted backup once per month on the 1st of the month at 1 AM and a database only encrypted backup runs every day at 2 AM. The full backup is uploaded to a Google Drive folder named “backup_full-example-com” and the five most recent backups are retained. The database only backup is uploaded to a Google Drive folder named “backup_db_only-example-com” and the thirty most recent backups are retained. Since the script depends on other binaries, e.g. mysqldump and drive, please make sure cron has access to the appropriate user path. If it doesn’t have access to the appropriate path, then add it to the crontab. Use the command env $PATH todisplay the current user path and replace the example below with the appropriate path for the environment.

env $PATH

crontab -e

PATH=$PATH:/home/bitnami/bin:/opt/bitnami/mysql/bin

0 1 * * 1 /home/bitnami/scripts/backup.sh -e -f -n 5 -d backup_full-example-com
0 2 * * * /home/bitnami/scripts/backup.sh -e -n 30 -d backup_db_only-example-com

crontab -l

Decrypting

Let’s assume everything is executing as expected and Google Drive is populated with backups from the script. Download a backup from Google Drive to your local machine. Since the file is encrypted, you won’t be able to open it directly. In order to decrypt the file, OpenSSL is used with the -d flag and the same options that were used to encrypt it. Specify the filename of the downloaded encrypted file with the -in option and the filename of resulting decrypted file in the -out option. When prompted, please be sure to enter the same password used by the script to encrypt the file (found in the .backup.cnf file). If you are downloading/decrypting on a Windows machine, you will need to install OpenSSL.

openssl enc -d -a -md sha512 -pbkdf2 -iter 100000 -salt -AES-256-CBC -in "c:\backup_full-example-com_2021_03_23_01_00.tar.gz" -out "c:\decrypted_backup_full-example-com_2021_03_23_01_00.tar.gz"

The resulting decrypted file is now readable as a regular tar.gz file.

Automate Amazon Lightsail Maintenance Activities Using Bash Script

With Amazon Lightsail, like most other virtual private servers (VPS), you are responsible for performing server maintenance activities, e.g. applying patches, once the instance is running. While it’s easy to perform this manually based on a calendar reminder, it’s also easy to forget to do it periodically. When added to the user crontab, the following bash script automatically performs OS patches on a schedule that you define. Please be aware that most Lightsail images are Bitnami based and this method will not apply upgrades/patches on its packaged applications. Updating Apache, for example, requires moving to a new instance with the latest Bitnami image.

Since this example assumes a Lightsail environment based on a Bitnami stack (Debian), user and path specifics may need change for your specific environment.

As a personal preference, I write user scripts to a “scripts” directory in my home directory. This first step creates a new “scripts” directory in my home directory with 700 permissions (read, write, execute). Then, I create a blank file named “maintenance.sh” to contain the script. I prefer to use vim to edit files, but please feel free to adjust to your preferred editor.

mkdir -m 700 ~/scripts

touch ~/scripts/maintenance.sh
chmod 700 ~/scripts/maintenance.sh
vim ~/scripts/maintenance.sh

The next step is to add the script to the blank maintenance.sh file. The script performs only a few actions: “apt update” retrieves the latest list of available packages and versions, but it does not upgrade any packages; “apt upgrade” upgrades existing/installed packages to the latest version. Finally, “apt autoclean” removes package files from the local repository that have been uninstalled or they are no longer available for downloaded.

#!/bin/bash

# https://www.dalesandro.net/automate-amazon-lightsail-maintenance-activities-using-bash-script/

sudo apt update && sudo apt upgrade -y
sudo apt autoclean

The last step is the schedule the job using cron. The next command allows you to edit the user crontab.

crontab -e

As an example, add the following line to the user crontab to schedule the script to run at 3:00 AM every Sunday.

0 3 * * 0 /home/bitnami/scripts/maintenance.sh

Exit the editor and the your maintenance activities will be performed automatically as scheduled.

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