15. Install WordPress and use it to serve HTTPS websites

by Cover Tower - Updated September 13, 2021

Serving websites over plain HTTP is not recommended. As we mentioned earlier, in the very rare cases were you want to serve websites over plain HTTP, you should do it only for very simple presentation websites, whose content very rarely changes, consisting of just a few web pages or

even of a single web page. These websites are in general so simple that they don’t need a database

and you don’t need to install WordPress to manage them. We explained earlier how to configure Nginx to serve such websites.

If on the contrary, the sites are more complex and you have to frequently modify their content by adding text, pictures, etc., which is almost always the case, then you will need websites with their content stored in databases, so you will need WordPress, because WordPress is the simplest and most efficient way to install and manage such websites. Then, if you install WordPress, you will need to serve the websites over HTTPS, because otherwise whenever you log in to the admin area of your sites, you will send your login credentials unencrypted over the Internet, which can allow attackers to intercept them in transit. Also, if your visitors will log in to your website, they will send their credentials unencrypted. For this reason and for other numerous reasons that we mentioned earlier, when installing WordPress, you should always use it in conjunction with the HTTPS protocol.

We’ll explain below how to install WordPress and use it to serve websites over HTTPS.

All the settings from below are for a website with the domain www.example.com, where example.com is your own domain. Please note: we called example.com your main domain, which means the domain that you consider more prominent among all the domains that you host, but what we describe in this chapter can be applied for the main domain, as well as for any other domain hosted on your server, that you want to use for a WordPress website: seconddomain.net, thirddomain.info, fourthdomain.org, etc. Therefore, if we exemplify all the settings in this chapter with example.com it doesn’t mean that these settings can be applied only for the main domain.

Of course, instead of www.example.com you can have a domain without www, of the form example.com. In that case you will have to make the redirects from www to non-www in a way similar to how we describe below the redirects from non-www to www, in the Nginx server blocks configuration file.

15.1. Pitfalls to avoid when using WordPress

WordPress is a time-tested, efficient and extremely flexible Content Management System (CMS). The main quality that made WordPress the most popular CMS, for all types of websites, is the fact that it solves all the problems associated with website management, in the simplest way, as compared to all the other free and open source CMSs. In a world that becomes more and more complicated every day, the application that wins the battle in the long run is the one that solves complicated problems in the simplest way possible.

Since WordPress is free and open source software, and the collection of themes and plugins on wordpress.org is open to the public, everyone can add their own plugin or theme to that already enormous collection. This is a good thing but it also creates problems: there are many themes and plugins of low quality, from all the points of view, and if you install them, they can drastically decrease web page loading speed, they can conflict with other plugins, they can block functionalities of your website, they can create security vulnerabilities and they can even crash the entire website, usually on upgrade. Also, many themes and plugins have a good-looking but almost useless gratis version, which is used as a bait, to pressure users to buy the ‘pro’ version, which is of low quality itself. This is not the fault of WordPress developers or of the managers of the wordpress.org themes and plugins repository. Every theme and plugin that is uploaded to wordpress.org is checked for malicious features, viruses, etc. Yet they are not checked for conflicts with all the other plugins or themes, for code efficiency, for honesty of intentions, etc. This would be in fact impossible. The bottom line is that you have to know in advance what themes and plugins deserve to be installed, for every particular type of website, in order to avoid loosing time, money, patience, hope. Installing bad WordPress plugins or themes can lead to years-long frustration,enormous time waste and even money waste. We recommend that you take a look at our list of good quality WordPress themes and plugins before installing general purpose themes or plugins for frequently needed functionalities.

We also recommend that after you install WordPress as we describe below, you always keep it up-to-date by constantly updating WordPress itself, the theme you will be using and all the installed plugins.

If installed, configured and managed properly, WordPress will be both fast and secure.

15.2. Add the DNS records for the new domain

Before installing WordPress, you have to add the necessary A and AAAA DNS records for the domain of the website that you want to install. In the Add DNS records using Hurricane Electric’s free DNS services chapter, we explained how to add A and AAAA records for www.example.com. Even if you don’t use the Hurricane Electric’s free DNS services, the A and AAAA records should look similar and you should add them more or less similarly, depending on the web interface of your DNS services provider.

15.3. Generate user passwords for basic HTTP authentication, with the htpasswd tool

It’s recommended to use basic HTTP authentication to protect the login pages served by your server. The login pages of your WordPress websites should not be an exception. To restrict access to the customized login page of a WordPress website, you should first generate user passwords for HTTP authentication, for every user that will have access to that login page.

Since you may host multiple websites, each having a distinct set of users allowed to log in to the administration section, you should create a different password file for each website. Each password file will contain the usernames and hashed passwords of the set of users with access to the login page for that website. It’s better to name each password file after the domain of the website, so that you can easily distinguish these files in the future.

We’ll create passwords for the www.example.com website. We’ll use the htpasswd tool that we already installed, to create a password file in the /etc/nginx/htpass directory. If you want to give access to the login page to the user brandon, run the following command:

htpasswd -c /etc/nginx/htpass/www.example.com brandon

This command will prompt you to type and retype a password for the user brandon and will create a file called www.example.com in which it will store the username followed by the password, hashed with the default MD5 algorithm. The content of the file will look similar to this:

brandon:$apr1$dNwvAUPd$JROs/szkb7MAqN99/sNCm1

Change ownership and permissions for the password file:

cd /etc/nginx/htpass

chown www-data:root www.example.com

chmod 400 www.example.com

To add the credentials for a new user jerry to the /etc/nginx/htpass/example.com file, you should run a similar command but without the -c option, because the file is already created:

htpasswd /etc/nginx/htpass/www.example.com jerry

So, if you have a website for which you want the users brandon, jerry, bob and alice to access the WordPress login page, and you want to protect that page with basic HTTP authentication, you have to create a password file that will contain the credentials of all the four users. This file will be referenced in a configuration block that has to be included inside the server block for that respective WordPress websites. So, right above the location / block of the WordPress website, you should add the following configuration block:

location /get-web-net {

auth_basic ‘Restricted’;

auth_basic_user_file /etc/nginx/htpass/www.example.com;

try_files $uri $uri/ /index.php?$args;

}

where get-web-net is the customized name of the login page. We’ll explain further down below how to change the name of the login page, from the default wp-login.php to a custom name, easy to remember for you but difficult to guess for an attacker.

15.4. Get a SSL certificate for the new domain

After you added the DNS records and waited a few minutes or hours to allow them to propagate, you can install a SSL certificate for the new HTTPS website.

We’ll describe below how to obtain and renew the gratis Let’s Encrypt SSL certificates, and the paid Comodo Positive SSL certificates. (Certificates from other Certificate Authorities can be obtained and installed by following the instructions on their respective official websites.)

We already explained how to obtain a Let’s Encrypt SSL certificate for the mail.example.com domain but in the following chapters we’ll describe how to install Let’s Encrypt certificates as well as Comodo Positive certificates in a general case, that is for a website with a domain of the form www.example.com. Whenever you want to install a new HTTPS website on your server you can follow again all the steps in the Install WordPress and use it to serve HTTPS websites chapter.

15.5. Install a Let’s Encrypt SSL certificate

15.5.1. Configure Nginx

The Let’s Encrypt verification process will look for certain verification files created by Certbot in the .well-known/acme-challenge directory, located in the server’s webroot.

This means that these files must be publicly accessible. For this we need to include a location /.well-known/acme-challenge directive in the server block for the website for which we request the SSL certificate. Open the /etc/nginx/sites-enabled/0-conf file:

nano /etc/nginx/sites-enabled/0-conf

At the bottom of this file add the following temporary server block that we’ll use to obtain the Let’s Encrypt SSL certificate for our domain. After we obtain the certificate, we’ll replace this block with a proper, complete server block necessary to serve the website over HTTPS:

server {

listen 80;

listen [::]:80;

server_name www.example.com example.com;

location /.well-known/acme-challenge {

root /var/www;

}

}

Reload Nginx to apply the changes:

systemctl reload nginx

Please note the location /.well-known/acme-challenge directive and the fact that we mentioned both www.example.com and example.com as server name. This is because we want to obtain a SSL certificate that is valid for both www.example.com and example.com, so that we can then properly redirect requests from one to the other. Let’s Encrypt will issue a certificate that is valid for both www.example.com and example.com, but if you want certificates for other subdomains of example.com, such as lists.example.com or office.example.com, it’s recommended to request separate SSL certificates for each subdomain, as we’ll show later in this guide, although Let’s Encrypt can issue wildcard certificates that are valid for all subdomains of a domain.

15.5.2. Get the certificate

To obtain a SSL certificate valid for both www.example.com and example.com run:

certbot certonly –agree-tos –webroot -w /var/www/ -d www.example.com -d example.com

If the SSL certificate is successfully generated, the output will look like this:

– Congratulations! Your certificate and chain have been saved at:

/etc/letsencrypt/live/www.example.com/fullchain.pem

Your key file has been saved at:

/etc/letsencrypt/live/www.example.com/privkey.pem

Your cert will expire on 2020-09-20. To obtain a new or tweaked

version of this certificate in the future, simply run certbot

again. To non-interactively renew *all* of your certificates, run

“certbot renew”

– If you like Certbot, please consider supporting our work by:

Donating to ISRG / Let’s Encrypt: https://letsencrypt.org/donate

Donating to EFF: https://eff.org/donate-le

The private key for the certificate will be:

/etc/letsencrypt/live/www.example.com/privkey.pem

The intermediate certificates used for OCSP stapling will be:

/etc/letsencrypt/live/www.example.com/chain.pem

The certificate and intermediate certificate concatenated in the correct order will be:

/etc/letsencrypt/live/www.example.com/fullchain.pem

The certificate (not to be used in Nginx configuration; we’ll use the fullchain.pem instead) will be:

/etc/letsencrypt/live/www.example.com/cert.pem

These files are in fact symlinks to the most recent version of the certificate package files, which are located in /etc/letsencrypt/archive/www.example.com . When you renew the Let’s Encrypt certificates, the Certbot client will automatically update the symlinks, to make them point to the most recent version of the files. The generated files and symlinks must be left in their default locations.

15.5.3. Update the server blocks configuration file

Edit the /etc/nginx/sites-enabled/0-conf file:

nano /etc/nginx/sites-enabled/0-conf

Replace the simplified server block for www.example.com mentioned above with the following server blocks, in order to perform the right redirection to the www domain and allow Nginx to serve the site over HTTPS:

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

return 301 https://www.example.com$request_uri;

}

server {

listen 443 ssl http2;

listen [::]:443 ssl http2;

server_name example.com;

ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem;

ssl_trusted_certificate /etc/letsencrypt/live/www.example.com/chain.pem;

return 301 https://www.example.com$request_uri;

}

server {

listen 443 ssl http2;

listen [::]:443 ssl http2;

server_name www.example.com;

root /var/www/example.com;

index index.php;

ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem;

ssl_trusted_certificate /etc/letsencrypt/live/www.example.com/chain.pem;

ssl_dhparam /etc/nginx/ssl/dhparam.pem;

ssl_session_timeout 4h;

ssl_session_cache shared:SSL:40m;

   ssl_protocols TLSv1.2 TLSv1.3;
   ssl_prefer_server_ciphers on;
   ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

   ssl_stapling on;
   ssl_stapling_verify on;
   add_header Strict-Transport-Security "max-age=63072000" always;
   add_header X-Frame-Options SAMEORIGIN;
   add_header X-Content-Type-Options nosniff;

location = /robots.txt {

allow all;

}

location /get-web-net {

auth_basic ‘Restricted’;

auth_basic_user_file /etc/nginx/htpass/www.example.com;

try_files $uri $uri/ /index.php?$args;

}

location / {

try_files $uri $uri/ /index.php?$args;

# Prevent image hotlinking

location ~ .(jpg|jpeg|png|svg|gif)$ {

valid_referers none blocked ~.google. ~.bing. ~.yahoo. example.com *.example.com;

if ($invalid_referer) {

return 403;

}

}

}

location /.well-known/acme-challenge {

root /var/www;

}

location ~ \.php$ {

try_files $uri =404;

fastcgi_split_path_info ^(.+\.php)(/.+)$;

include fastcgi_params;

fastcgi_index index.php;

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

fastcgi_param HTTPS on;

fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;

}

# Block XML-RPC requests

location = /xmlrpc.php {

deny all;

}

# Browser caching

location ~* \.(css|js|gif|jpeg|jpg|png|ico)$ {

expires 72h;

add_header Pragma public;

add_header Cache-Control “public”;

}

# Block public access to .php files inside two sensitive directories

location ~* /wp-includes/.*.php$ {

deny all;

}

location ~* /wp-content/.*.php$ {

deny all;

}

access_log /var/log/sites/example.com/access.log;

error_log /var/log/nginx/example.com.error.log notice;

}

Change example.com with your real domain name.

Create the directory for the access log:

mkdir /var/log/sites/example.com

Reload Nginx:

systemctl reload nginx

As you can see, the configuration from above mentions WordPress. WordPress installation is described further down below.

Please note the presence of the following section:

location /.well-known/acme-challenge {

root /var/www;

}

This section should be present in all the server blocks of HTTPS websites, to allow access to the /var/www/.well-known/acme-challenge directory, otherwise the Let’s Encrypt SSL certificate renewal process will fail.

The key points of the configuration presented above are the following:

ssl_session_cache shared:SSL:40m; creates a 40MB cache shared between all worker processes. This cache is used to store SSL session parameters to avoid SSL handshakes for parallel and subsequent connections. 1MB can store about 4000 sessions. You can adjust this cache size according to the expected traffic of your website.

ssl_session_timeout 4h; specifies the SSL session cache timeout. Here it’s set to 4 hours, but it can be decreased or increased, depending on traffic and resources.

ssl_protocols TLSv1.2 TLSv1.3; enables the TLSv1.2 and TLSv1.3 protocols.

ssl_prefer_server_ciphers on; means that when an SSL connection is established, the server ciphers are preferred over client ciphers.

ssl_ciphers specifies the enabled ciphers.

ssl_stapling on; enables stapling of OCSP responses by the server.

ssl_stapling_verify on; enables verification of OCSP responses by the server.

add_header Strict-Transport-Security “max-age=63072000”; tells web browsers they should only interact with this server using a secure HTTPS connection. The max-age specifies in seconds what period of time the site is willing to accept HTTPS-only connections.

add_header X-Frame-Options SAMEORIGIN; tells the browser that web pages can only be displayed in a  <frame>, <iframe>, <embed> or <object> element on the same website that serves the pages.

add_header X-Content-Type-Options nosniff; this header tells the browser not to override the response content’s MIME type. So, if the server says the content is text, the browser will render it as text.

access_log and error_log indicate the location and name of the respective types of logs.

The fastcgi directives are necessary to proxy requests for PHP code execution to PHP-FPM, via the FastCGI protocol.

The listen [::]:80; and listen [::]:443 ssl http2; directives are needed if you want your site to be also accesible over IPv6. If you don’t want the site to be accessible over IPv6 or your server doesn’t have IPv6 connectivity, you can remove these directives. In general, you’ll want to have the website accessible over both IPv4 and IPv6, since IPv6 is widely used and it seems it will be the norm in the future.

You can verify the quality of your SSL connection here. Please note that to achive an A+ grade like below, you’ll need to remove the catch-all server block temporarily from the top of the /etc/nginx/sites-enabled/0-conf file. Otherwise ssllabs.com will also analyze the catch-all block and since it contains a self-signed SSL certificate, the final grade for the properly configured HTTPS website will be just A or lower which is not because of the SSL configurations presented above but because of the self-signed certificate of the catch-all block.



Earlier, in the “Automate the Let’s Encrypt SSL certificates renewal” chapter we described how to set up a cron job to automate the Let’s Encrypt SSL certificates renewal; the cron job that we set up will renew all the Let’s Encrypt certificates that are up for renewal. Also, in the “Configure logrotate to rotate Let’s Encrypt logs” chapter we described how to configure logrotate to rotate the Let’s Encrypt logs, so all that remains to be explained, related to Let’s Encrypt certificates, is to show how to properly remove a Let’s Encrypt certificate for a website or application.

15.5.4. Removing a Let’s Encrypt SSL certificate


If you want to remove a Let’s Encrypt SSL certificate, the way to completely remove it is by deleting the /etc/letsencrypt/renewal/example.com.conf file, the /etc/letsencrypt/archive/example.com directory with all its content, and the /etc/letsencrypt/live/example.com directory with all its content.

Then, if you want to request a new certificate for that domain, just run again:

certbot certonly –agree-tos –webroot -w /var/www/ -d www.example.com -d example.com

15.5.5. Installing a Comodo Positive SSL certificate

If instead of using a gratis Let’s Encrypt SSL certificate you decide to buy a Comodo Positive SSL certificate (you can buy one at a cheap price here you should follow these steps to properly install it:

- Generate a RSA private key and a Certificate Signing Request (CSR): 

cd /etc/nginx/ssl
openssl req -newkey rsa:2048 -nodes -keyout myserver.key -out server.csr

You will be asked a few questions:

Country Name (2 letter code) [AU]:US
State or Province Name (full name) [Some-State]:IL
Locality Name (eg, city) []:Chicago
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Global Bears Inc       
Organizational Unit Name (eg, section) []:IT
Common Name (e.g. server FQDN or YOUR name) []:www.mydomain.com
Email Address []:

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:


Replace the values in red with your own data. The fields Email Address, A challenge password, and An optional company name can be left blank by pressing Enter.

If the website is www.mydomain.com, the Common Name should be: www.mydomain.com. Change permissions for myserver.key:

chmod 400 myserver.key

- After you buy the Comodo Positive SSL certificate you will receive an email with a link and an access code. Access the link in a browser, enter the access code and your email address, which must be on the same domain as the domain for which you want to obtain the SSL certificate, click on 'Check access code', then in the self-enrollment form, in the 'CSR' field, paste the content of the server.csr file generated earlier, then fill in all the other necessary fields (you can leave the address fields empty), then click 'Submit'. You will receive another email message with an attachment containing your SSL certificate.

- Concatenate the three files sent by Comodo to make a single SSL bundle file to use with Nginx (the fourth file, 'AddTrustExternalCARoot.crt' can be ignored):

cat www_yourdomain_com.crt COMODORSADomainValidationSecureServerCA.crt COMODORSAAddTrustCA.crt > ssl-bundle.crt

Add the appropriate SSL directives to the /etc/nginx/sites-enabled/0-conf file, in the server block of the website for which you requested the certificate:
 
ssl_certificate      /etc/nginx/ssl/yourdomain/ssl-bundle.crt;
ssl_certificate_key  /etc/nginx/ssl/yourdomain/myserver.key;

15.5.6. Enabling OCSP Stapling for a website with a Comodo SSL certificate

This page contains the following three files that must be concatenated in this order in a file saved as /etc/nginx/ssl/full_chain.pem:

1. COMODORSAAddTrustCA.crt

2. COMODORSADomainValidationSecureServerCA.crt

2. AddTrustExternalCARoot.crt

Paste the content copied from this page to the files with the same respective names created in the /etc/nginx/ssl directory:

cd /etc/nginx/ssl

nano COMODORSAAddTrustCA.crt

nano COMODORSADomainValidationSecureServerCA.crt

nano AddTrustExternalCARoot.crt

To concatenate them run:

cat COMODORSAAddTrustCA.crt COMODORSADomainValidationSecureServerCA.crt AddTrustExternalCARoot.crt > full_chain.pem

For OCSP Stapling to work, add the following lines to the Nginx server block of the HTTPS website:

ssl_stapling on;

ssl_stapling_verify on;

ssl_trusted_certificate /etc/nginx/ssl/full_chain.pem;

All the other SSL settings in the server block of the HTTPS website (ssl_session_cache, ssl_protocols, ssl_ciphers, etc.) will be the same settings as described earlier, for the Let’s Encrypt certificate.

Reload Nginx:

systemctl reload nginx

15.5.7. Renewing a Comodo Positive SSL certificate

To renew a Comodo Positive SSL certificate, you will have to buy and configure a new certificate for the same domain, following the same steps described above. It’s recommended to write down the expiration date for every SSL certificate that you buy, so that you can remember to renew it in the month previous to its expiration, at the latest.

15.5.8. Redirecting HTTPS to HTTP

Please note that the HTTP website described earlier, namely www.example-http-only.com , was configured in such a way that all requests for http://example-http-only.com were redirected to http://www.example-http-only.com . But what happens if someone requests https://example-http-only.com or https://www.example-http-only.com in a browser ? The browser will show an error. You may find this good enough, because the site wasn’t supposed to be served over HTTPS. Yet, the most complete way to configure a HTTP-only website would be to also redirect HTTPS requests to http://www.example-http-only.com. If you do this redirect using the dummy self-signed SSL certificates, the visitor’s browser will display the ‘not trusted’ warning, therefore, to properly make the redirection from HTTPS to HTTP in this case, you will have to first get a Let’s Encrypt SSL certificate for www.example-http-only.com , then use that certificate in a redirection block, similar to the 443 redirection block for www.example.com presented earlier.

15.6. Implement FastCGI cache

15.6.1. Configure Nginx to use FastCGI cache

As mentioned earlier, FastCGI cache is the best page caching that can be implemented for Nginx. Since it drastically increases page loading speeds, you will want to add FastCGI caching to all the websites hosted on your server. To implement FastCGI caching first create the cache directory:

mkdir /var/cache/nginx

Change ownership and permissions for this directory:

cd /var/cache

chown www-data:root nginx

chmod 700 nginx

Edit the /etc/nginx/nginx.conf file:

nano /etc/nginx/nginx.conf

In the http block, right above the map $http_upgrade $connection_upgrade { line, add the following 2 lines:

fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=phpcache:500m inactive=60m;

fastcgi_cache_key “$scheme$request_method$host$request_uri”;

The first directive: fastcgi_cache_path creates a FastCGI cache. The first argument is the cache location in the file system (/etc/nginx/cache). The levels parameter defines the structure of the cache subdirectories. The 3rd argument specifies the memory zone name (phpcache) and the cache size (500m). For a server with a total RAM of 2 GB, a cache size of 500 MB is reasonable. Data which has not been accessed during the inactive time period (60 minutes) will be removed from cache. Once the cache reaches its maximum size, the Nginx cache manager will remove the oldest data first.

The 2nd directive: fastcgi_cache_key defines the key for cache lookup.

15.6.2. Edit the server blocks configuration file


Then open the server blocks configuration file:

nano /etc/nginx/sites-enabled/0-conf

You’ll have to edit the location ~ \.php$ section of each website for which you want to implement FastCGI caching.

Add the following lines inside the location ~ \.php$ section, at the end:

fastcgi_cache phpcache;

fastcgi_cache_valid 200 301 302 60m;

add_header X-FastCGI-Cache $upstream_cache_status;

The fastcgi_cache directive enables the memory cache previously created by the fastcgi_cache_path directive. If you don’t include this directive, your server block will not use the cache.

The fastcgi_cache_valid sets the cache time depending on the HTTP status code. In the example above, responses with status code 200, 301, 302 will be cached for 60 minutes.

The 3rd line adds a header to the response headers, that can be used to check whether the requested page has been served from the FastCGI cache or not. If you access a web page served with FastCGI cache and look at the response headers (right-click on page and choose ‘Inspect’/’Inspect Element’ > ‘Network’ > ‘Headers’), you will see the following header:

x-fastcgi-cache HIT

Other possible values are MISS (the page hasn’t been served from the cache because it was the first time it was requested in a long time) and BYPASS (the cache is bypassed for that page because it’s an admin page, an WooCommerce checkout page, etc.).

After you test the FastCGI cache configuration and see that it works, you can comment out the line that adds the header, to make it look like this:

# add_header X-FastCGI-Cache $upstream_cache_status;

Reload Nginx:

systemctl reload nginx

15.6.3. Exclude certain elements from FastCGI caching


Login sessions, user cookies, POST requests, query strings, WordPress back-end pages, site maps, feeds and comments should not be cached.

If you use your site(s) to sell products/services with WooCommerce, since product sale prices are in general displayed including the sales taxes, and since these taxes depend on the geographic location of the visitors, you will want to activate the ‘Geolocation’ feature of WooCommerce, so that the sales taxes added to the base price, which will change the final price seen by the visitor depending on their location, as well as the shipping costs, are calculated and applied automatically. On the other hand, if you do this, once a product page is cached, the visitor from one country can see the cached page valid for a different country, where the taxes are different, so they will see a wrong price. To avoid this type of problems, unless you sell only to customers inside the country/state where the store is located, it’s recommended to activate the ‘Geolocation’ feature of WooCommerce and exclude from caching the cart, checkout, and my-account pages.

The shop pages and single product pages can be fully cached if you don’t sell outside your country, but cart, checkout, and my-account pages should be excluded from caching.

WooCommerce store pages, the cart page, the my-account pages, the checkout page, and the addons page, as shown below.

To disable FastCGI caching for the above items, edit the /etc/nginx/sites-enabled/0-conf file again:

nano /etc/nginx/sites-enabled/0-conf

In the location ~ \.php$ section, add the following directives at the end:

fastcgi_cache_bypass $skip_cache;

fastcgi_no_cache $skip_cache;

Therefore, the location ~ \.php$ section should look like this:

location ~ \.php$ {

try_files $uri =404;

fastcgi_split_path_info ^(.+\.php)(/.+)$;

include fastcgi_params;

fastcgi_index index.php;

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

fastcgi_param HTTPS on;

fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;

fastcgi_cache phpcache;

fastcgi_cache_valid 200 301 302 60m;

add_header X-FastCGI-Cache $upstream_cache_status;

fastcgi_cache_bypass $skip_cache;

fastcgi_no_cache $skip_cache;

}

Then below this section:

# Block XML-RPC requests

location = /xmlrpc.php {

deny all;

}

add the following sections:

set $skip_cache 0;

# POST requests and URLs with a query string should not be cached

if ($request_method = POST) {

set $skip_cache 1;

}

if ($query_string != “”) {

set $skip_cache 1;

}

# Don’t cache URLs containing the following segments

if ($request_uri ~* “/wp-admin/|/get-web-net|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml”) {

set $skip_cache 1;

}

# Don’t use the cache for logged in users or recent commenters

if ($http_cookie ~* “comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in”) {

set $skip_cache 1;

}

# If you use WooCommerce add the following block to exclude from caching certain WooCommerce pages

   if ($request_uri ~* "/cart.*|/checkout.*|/my-account.*") {
       set $skip_cache 1;
   }

Replace get-web-net with the custom name of your WordPress login page, so that brute-force attackers will face an additional obstacle if they try to guess your log in credentials. Further down below we’ll explain how to install the ‘WPS Hide Login’ WordPress plugin to change the name of the login page from the default wp-login.php to a custom name.

If you installed WooCommerce in a different language than American English, replace cart, checkout and my-account with the actual name of those pages in the language that you use. It’s important to note that the shop pages and the individual product pages should be cached like any other regular pages of your site, using FastCGI cache. This will significantly increase the page loading speed and will decrease the server load.

As we mentioned before, if you sell products or services to customers outside your country/state/province, and you use the ‘Geolocate’ option in WooCommerce to automatically change the displayed price of products/services according to the location of the visitor, by adding the corresponding sales tax, you wouldn’t want a visitor from one country to see the cached product page of a visitor from another country. Yet single product pages and store pages should be cached (using FastCGI page caching), because it’s extremely important to offer visitors the fastest loading store and product pages possible, otherwise, they can leave the site. In this situation it’s recommended to display a prominent notice on single product pages, next to the product price, informing the visitor that different sales/VAT/GST taxes may apply, depending on their location. In this way, when the visitor adds the product to cart and goes to checkout, they will not be surprised to see a different price on the checkout page, which is not cached and therefore it displays the taxes applicable to the visitor’s detected location, which usually results in a total product price slightly different from what the visitor saw on the product page.

If occasionally, you want to test how the website works without caching and therefore you want to bypass FastCGI cache for certain IP addresses, you can add the following block to the ones mentioned above:

if ($remote_addr ~* "123.123.123.123|124.124.124.124") {
     set $skip_cache 1;
}

where 123.123.123.123 and 124.124.124.124 are the IPs for which you want to bypass FastCGI cache.

In conclusion, the complete settings for www.example.com in the /etc/nginx/sites-enabled/0-conf file should look like this:

server {

listen 80;

listen [::]:80;

server_name example.com www.example.com;

return 301 https://www.example.com$request_uri;

}

server {

listen 443 ssl http2;

listen [::]:443 ssl http2;

server_name example.com;

ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem;

ssl_trusted_certificate /etc/letsencrypt/live/www.example.com/chain.pem;

return 301 https://www.example.com$request_uri;

}

server {

listen 443 ssl http2;

listen [::]:443 ssl http2;

server_name www.example.com;

root /var/www/example.com;

index index.php;

ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem;

ssl_trusted_certificate /etc/letsencrypt/live/www.example.com/chain.pem;

ssl_dhparam /etc/nginx/ssl/dhparam.pem;

ssl_session_timeout 4h;

ssl_session_cache shared:SSL:40m;

ssl_protocols TLSv1.2 TLSv1.3;

ssl_prefer_server_ciphers on;

ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

ssl_stapling on;

ssl_stapling_verify on;

add_header Strict-Transport-Security “max-age=63072000” always;

add_header X-Frame-Options SAMEORIGIN;

add_header X-Content-Type-Options nosniff;

location = /robots.txt {

allow all;

}

location /get-web-net {

auth_basic ‘Restricted’;

auth_basic_user_file /etc/nginx/htpass/www.example.com;

try_files $uri $uri/ /index.php?$args;

}

location / {

try_files $uri $uri/ /index.php?$args;

# prevent image hotlinking

location ~ .(jpg|jpeg|png|svg|gif)$ {

valid_referers none blocked ~.google. ~.bing. ~.yahoo. example.com *.example.com;

if ($invalid_referer) {

return 403;

}

}

}

location /.well-known/acme-challenge {

root /var/www;

}

location ~ \.php$ {

try_files $uri =404;

fastcgi_split_path_info ^(.+\.php)(/.+)$;

include fastcgi_params;

fastcgi_index index.php;

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

fastcgi_param HTTPS on;

fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;

fastcgi_cache phpcache;

fastcgi_cache_valid 200 301 302 60m;

add_header X-FastCGI-Cache $upstream_cache_status;

fastcgi_cache_bypass $skip_cache;

fastcgi_no_cache $skip_cache;

}

# Block XML-RPC requests

location = /xmlrpc.php {

deny all;

}

set $skip_cache 0;

# POST requests and urls with a query string should always go to PHP

if ($request_method = POST) {

set $skip_cache 1;

}

if ($query_string != “”) {

set $skip_cache 1;

}

# Don’t cache uris containing the following segments

if ($request_uri ~* “/wp-admin/|/get-web-net|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml”) {

set $skip_cache 1;

}

# Don’t use the cache for logged in users or recent commenters

if ($http_cookie ~* “comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in”) {

set $skip_cache 1;

}

# If you use WooCommerce add the following block to exclude from caching certain WooCommerce pages

if ($request_uri ~* "/cart.*|/checkout.*|/my-account.*") {

set $skip_cache 1;

}

# Browser caching

location ~* \.(css|js|gif|jpeg|jpg|png|ico)$ {

expires 72h;

add_header Pragma public;

add_header Cache-Control “public”;

}

# Block public access to .php files inside two sensitive directories

location ~* /wp-includes/.*.php$ {

deny all;

}

location ~* /wp-content/.*.php$ {

deny all;

}

access_log /var/log/sites/example.com/access.log;

error_log /var/log/nginx/example.com.error.log notice;

}

Reload Nginx:

systemctl reload nginx

15.6.4. Configure FastCGI cache to be served from RAM

The RAM is much faster than the hard drive, therefore, for best FastCGI cache performance, you have to serve the cached pages from RAM. Open the /etc/fstab file:

nano /etc/fstab

Add this line at the end of the file:

tmpfs /var/cache/nginx tmpfs defaults,size=500M 0 0

We assigned 500MB of RAM for the cache, but you can modify this value if needed. On server reboot, the content of the RAM memory, including the cache saved in memory, will be removed. However, since the server will be very rarely restarted, this will not be a problem at all.

Mount the new RAM partition by running:

mount -a

Check the result by running:

df -ah | grep tmpfs

You should see something like this:

tmpfs 500M 0 500M 0% /var/cache/nginx

15.6.5. Purging FastCGI Cache

If you want to purge the entire FastCGI cache manually, run:

rm -r /var/cache/nginx/*

This command will remove all the content of the FastCGI cache from RAM.

To purge the cache automatically, when you edit a WordPress page, so that the visitors can see the new updated version, you’ll have to install the Nginx Cache plugin. We’ll explain further down below how to install and configure the Nginx Cache plugin.

15.6.6. FastCGI caching for plain HTTP websites



As a parantheses to the current chapter: if you have a PHP based HTTP-only website and you want to add FastCGI caching to it, the server blocks in the /etc/nginx/sites-enabled/0-conf file should look like this:

server {

listen 80;

listen [::]:80;

server_name example-http-only.com;

return 301 http://www.example-http-only.com$request_uri;

}

server {

listen 80;

listen [::]:80;

server_name www.example-http-only.com;

root /var/www/example-http-only.com;

index index.php index.html;

location = /robots.txt {

allow all;

}

location / {

try_files $uri $uri/ /index.php?$args;

# prevent image hotlinking

location ~ .(jpg|jpeg|png|svg|gif)$ {

valid_referers none blocked ~.google. ~.bing. ~.yahoo. example-http-only.com *.example-http-only.com;

if ($invalid_referer) {

return 403;

}

}

}

location ~ \.php$ {

try_files $uri =404;

fastcgi_split_path_info ^(.+\.php)(/.+)$;

include fastcgi_params;

fastcgi_index index.php;

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;

fastcgi_cache phpcache;

fastcgi_cache_valid 200 301 302 60m;

add_header X-FastCGI-Cache $upstream_cache_status;

fastcgi_cache_bypass $skip_cache;

fastcgi_no_cache $skip_cache;

}

set $skip_cache 0;

# POST requests and urls with a query string should always go to PHP

if ($request_method = POST) {

set $skip_cache 1;

}

if ($query_string != “”) {

set $skip_cache 1;

}

# Browser caching

location ~* \.(css|js|gif|jpeg|jpg|png|ico)$ {

expires 72h;

add_header Pragma public;

add_header Cache-Control “public”;

}

access_log /var/log/sites/example-http-only.com/access.log;

error_log /var/log/nginx/example-http-only.com.error.log notice;

}

15.6.7. FastCGI caching vs Varnish caching

A long time ago we used to use Varnish as a caching proxy in front of Nginx. Due to Nginx’s power and versatility we could use an original setup to cache HTTPS websites, since Varnish didn’t support HTTPS caching: Nginx was used as a frontend to decrypt SSL traffic and send it to Varnish on port 80; then Varnish tried to serve the requested content from cache; if the content wasn’t found in cache, Varnish would send the request to backend Nginx on port 8080, Nginx would deliver it to Varnish, which would cache it, then send it to frontend Nginx to encrypt it and send it to the client. So, the same Nginx instance was used both as a frontend and a backend. In spite of this minimal and efficient setup, which was more efficient in terms of memory usage than the classic Nginx-Varnish-Apache scheme, after making some tests, we discovered that FastCGI cache plus Nginx is as fast or even faster than Varnish plus Nginx; so, in order to save CPU power and have a simpler setup with fewer potential failure points, we decided to remove Varnish entirely and replace it with FastCGI cache. Here are our ApacheBench test results for 1000 users in 60 seconds with 100 concurent users:

HTTPS Web PageThroughput of Varnish + NginxThroughput of Fastcgi Cache + Nginx
Home Page of Site 1157 requests/sec167 requests/sec
Home Page of Site 2171 requests/sec209 requests/sec
Individual Product Page191 requests/sec209 requests/sec
Non-cachable Cart Page23 requests/sec27 requests/sec

Indeed, the speed seemed to be rather equal during some tests when the network traffic was more intense, but the conclusion is that the speeds were close, with FastCGI cache a bit faster during some tests, so we couldn’t see why we would use Varnish anymore, since it consumed CPU power and RAM, it represented an additional potential point of failure and it was quite difficult to configure to skip caching certain pages.

We didn’t test Varnish for plain HTTP websites, but even if it had been faster in that case, which we doubt, it wouldn’t have justified replacing FastCGI cache with Varnish, since almost all the websites are served nowadays over HTTPS. Other related benchmarks confirm our findings.

15.7. Use phpMyAdmin to create the database, user and password for the new website

The next step is to create a database for the new website. We installed phpMyAdmin for this very purpose: to easily create and manage MariaDB databases. Thus, first log in to phpMyAdmin at:

https://mail.example.com/net-mrdblog

replace example.com with your domain and net-mrdblog with the custom alias that you set up for the phpMyAdmin directory. Once logged in, click ‘Databases’ in the upper bar, then enter a name for the new database in the ‘Database name’ text box, leave the format as ‘utf8mb4_general_ci’, then click ‘Create’ and the new database will be created. You can give any name to the new database but in order to easily recognize each database in the database list later, it’s recommended to set a suggestive name, such as example.com, if it’s for a website whose domain is www.example.com.

Then you have to create a new user and give him all the privileges (except GRANT) over the new database.

To create a new user click on the Home icon on the left panel, then click ‘User accounts’ in the upper bar, then click ‘Add user account’. Then in the ‘User name’ text fields enter Use text field and the name of the new user, thomas in our example from below, in the ‘Host name’ text fields enter Local and localhost, in the ‘Password’ text fields enter Use text field and a strong password made up of a mixture of lower case and upper case letters, numbers and symbols (except quotation marks), in the ‘Re-type’ field enter the same strong password, then don’t forget to write down the new username and password because you will need them later to configure WordPress, then leave everything as it is, scroll down to the bottom of the page and click ‘Go’.

Once the new user is created, click again ‘User accounts’ in the upper bar, then click ‘Edit privileges’ on the row of the new user, then click the ‘Database’ tab, then in the ‘Add privileges on the following database(s)’ text box select the name of the new database, example.com in our example, then click ‘Go’; on the next screen check the ‘Check all’ checkbox, then under ‘Administration’ uncheck ‘GRANT’, because the new user doesn’t need the GRANT privilege, then click ‘Go’.

15.8. Install WordPress and the recommended plugins

15.8.1. Install WordPress

Now that you have a SSL certificate for the new site, that Nginx is correctly configured to serve the new WordPress website over HTTPS, with FastCGI caching, browser caching, etc., now that you have a MariaDB database, a username and a password for the new website, you can finally install WordPress. As mentioned before, the new website’s domain is www.example.com.

First create the directory where you will store the WordPress files and switch to it:

mkdir /var/www/example.com

cd /var/www/example.com

Download the last version of WordPress from the official website:

wget https://wordpress.org/latest.zip

Extract the archive, move all the extracted files in the /var/www/example.com directory, remove the empty wordpress directory and the archive:

unzip latest.zip

cd wordpress

mv * ..

cd ..

rm -r wordpress latest.zip

Create the robots.txt file inside the /var/www/example.com directory:

nano /var/www/example.com/robots.txt

Add the following content inside this file, to discourage search engines to index the website while it’s under construction:

User-agent: *
Disallow: /

Important ! When the website is ready for publishing and you want search engines to crawl and index it, so that it will appear in the visitors’ search results, you will have to change the content of the /var/www/example.com/robots.txt file, to make it look like below:

User-agent: *

Disallow:

Sitemap: http://www.example.com/sitemap.xml

The Sitemap parameter is optional. Yet, mentioning the URL of the XML sitemap that is automatically generated by WordPress, or by a plugin like ‘Yoast SEO’, can improve SEO.

Now, set the right ownership and permissions for the /var/www/example.com directory and its content:

chown -R www-data:www-data /var/www/example.com
find /var/www/example.com -type d -exec chmod 750 {} +
find /var/www/example.com -type f -exec chmod 640 {} +

Now everything is ready to install WordPress using the web interface. Use your browser to access:

https://www.example.com

You will see the ‘Welcome to WordPress’ screen:

Click “Let’s go” to go to the next screen:

Here enter the name of the database that you set up earlier in phpMyAdmin, example.com in our example, then the username and password of the user with privileges over the example.com database. In the ‘Database Host’ field enter localhost and in the ‘Table Prefix’ field enter wp_ .Click ‘Submit’.

On the next screen click ‘Run the installation’.

On the ‘Welcome’ screen, in the ‘Site Title’ field enter the domain of the website, www.example.com, in the ‘Username’ field enter the username that you will use to log in to the admin area of your WordPress site, in the ‘Password’ field enter a strong password for the username: you can replace the automatically generated password with one of your choice; the password should be complicated, therefore if you can’t memorize it, you should save it in a secure location, because you will need it later. In the ‘Your Email’ address enter the admin email address for the new website, usually of the form admin@example.com. Leave the ‘Search Engine Visibility’ checkbox unchecked, then click ‘Install WordPress’.

The next screen will inform you that the installation was successfull.

Before logging in to your site, you’ll have to make a configuration change to the /var/www/example.com/wp-config.php file, as explained below.

15.8.1.1. Prevent WordPress from asking for FTP credentials and change the default revision and autosave policy

Because the user www-data is not the owner of the WordPress installation directory, in spite the fact that it has write permissions for the wp-content directory (and for other directories), WordPress will ask for FTP credentials each time you want to install or update a plugin or a theme, by displaying the following pop-up window:

To prevent this from happening, all you have to do is to add the following lines to the wp-config.php file, right below the /**#@-*/ line:

// Prevent WordPress from asking for FTP credentials when installing themes and plugins

define(‘FS_METHOD’, ‘direct’);

By default, WordPress saves a ‘revision’ version of a page or post in the database, each time it is modified. This seems to be a useful feature that can allow the author to see the previous modifications and to restore previous versions of pages or posts. However, in time, the number of revision versions stored in the database can become very large, since it’s unlimited by default. This can lead to an oversized database which will ultimately have a negative impact on the loading speed of the website. If you want to limit the number of revision versions saved by WordPress to the database, add the following line right below the define(‘FS_METHOD’, ‘direct’); line described above:

define(‘WP_POST_REVISIONS’, 3);

However, you can completely disable saving revisions to the database, by adding the following line instead of the line from above:

define(‘WP_POST_REVISIONS’, false );

If you implement the backup mechanism described in the Install Backup Manager chapter, you will have a backup version of each website database saved every 24 hours. This means that you will be able to restore each website that you host to an older version, if you are not content with recent changes. This is why you can completely disable revision saving.

By default, WordPress also autosaves each page or post every 60 seconds. This is in general not needed and can cause small glitches when editing content. To increase the interval to 4 minutes (240 seconds), add the following line right below the define(‘FS_METHOD’, ‘direct’); line:

define(‘AUTOSAVE_INTERVAL’, 240 );

15.8.1.2. Log in to your WordPress website

To log in to the admin area of your WordPress website navigate to:

https://www.example.com/wp-login.php

Here enter the username and password that you have just set up, then click ‘Log In’. Once logged in you will see the WordPress Dashboard:

Immediately after logging in to the admin area of your new WordPress site, you should set a nickname (like John Doe) in the ‘Users’ section, to prevent your login username to appear as the author of the default ‘Hello world!’ blog post, which is publicly accessible on the home page of your website.On the left panel go to ‘Users’, then click on your username to edit your profile, then in the ‘Nickname (required)’ field enter a nickname for the admin user and select it in the ‘Display name publicly as’ drop-down list, then click ‘Update Profile’. It’s recommended to keep your login username secret and publish all blog posts under a nickname (which can be your company’s name).

You may also want to go to ‘Settings’ > ‘Permalinks’ and under ‘Common Settings’ choose ‘Post name’, to set the post name as the slug in the URL of every page. This will improve the SEO.

It’s also recommended to go to ‘Appearance’ > ‘Themes’ and delete all the default themes, except the one that is enabled by default, which you can keep for tests. You can also go to ‘Plugins’ > ‘Installed Plugins’, select and delete the ‘Akismet’ and ‘Hellow Dolly’ default plugins which won’t be needed. You can use other anti-spam plugins like ‘Stop Spammers’ instead of ‘Akismet’. As a general rule, you should keep the number of plugins at a minimum, since they can slow down the website, they can create security vulnerabilities, they can generate problems when upgrading, etc.

15.8.1.3. Install a WordPress theme

You have to decide what theme you want to use for your website. To install a new theme, click on ‘Appearance’ on the left panel > ‘Add New’, search for the theme that you want by entering its name in the search box, then when you find it click ‘Install’.

You can install any free or paid WordPress theme that you like but it’s obvious that among the thousands of available WordPress themes, some are better than others in terms of design, features, customization options, loading speed, etc., while many of them are low quality themes, from all the points of view. So, you should know in advance which theme deserves to be installed and fits your particular situation. This will spare you a lot of time when trying to customize it or update it. We can recommend ‘GeneratePress’, which is a fast and flexible theme, although it can be difficult to customize by manually changing the code.

15.8.1.4. Create the child theme

When you customize a theme, you shouldn’t modify any of its files directly, instead you should create a ‘child theme’ and make all the customizations inside the child theme. In this way, when you update the parent theme, the customizations won’t be lost, because although the parent theme gets modified, the changes made in the child theme are applied on top of the styles, functions, etc. of the parent theme. In very rare cases, where you make only slight changes to the css of a theme using the customizer inside WordPress (Appearance > Customize), you won’t need to create a child theme. This is because the changes that you make using the customizer are saved in the database, therefore, when you update the theme, they won’t be lost. However, almost always you will need to add or remove certain functionalities using custom PHP code that has to added to the functions.php file and in this situations you will need a child theme.

You can use a plugin like Child Theme Configurator to automatically create a child theme, but the best method is to create it manually, since in this way you avoid installing and having to maintain an additional plugin. The general rule is that the number of plugins should be kept at the minimum, to avoid loading speed problems, security vulnerabilities, etc., so you should do manually everything that can be done manually.

A child theme is in fact a directory called parenttheme-child, with two files inside it: functions.php and style.css, where parenttheme is the name of the parent theme. This directory has to be placed inside the /var/www/example.com/wp-content/themes directory.

First create a directory called parenttheme-child inside the /var/www/example.com/wp-content/themes directory (replace parenttheme with the name of your actual parent theme):

cd /var/www/example.com/wp-content/themes

mkdir parenttheme-child

Next, create the style.css file inside the newly created directory:

cd parenttheme-child

nano style.css

Add the following content at the top of this file:

/*

Theme Name: ParentTheme Child

Template: parenttheme

Author: Your Name

Description: ParentTheme Child Theme

*/

Replace ParentTheme with the official name of your parent theme, replace parenttheme with the name of your parent theme as it appears in the /var/www/example.com/wp-content/themes directory (be aware that this value is case-sensitive), and Your Name with your nickname or name. You can also add other parameters like ‘Version’, ‘Theme URI’, ‘Author URI’, ‘Tags’, ‘License’, etc., but they are optional.

Then create the functions.php file:

nano functions.php

Add the following content at the top of this file:

<?php

// Exit if accessed directly

if ( !defined( ‘ABSPATH’ ) ) exit;

function theme_enqueue_styles() {

wp_enqueue_style( ‘parent-style’, get_template_directory_uri() . ‘/style.css’ );

wp_enqueue_style( ‘child-style’, get_stylesheet_directory_uri() . ‘/style.css’, array(‘parent-style’, ‘stylesheethandle1’, ‘stylesheethandle2’, ‘stylesheethandle3’ ));

}

add_action( ‘wp_enqueue_scripts’, ‘theme_enqueue_styles’ );

parent-style and child-style are the ‘handles’ of the enqueued stylesheets. You can replace them with other names of your choice, but they must be unique across the theme.

Please note that stylesheethandle1, stylesheethandle2, stylesheethandle3 should be replaced with the list of handles of the stylesheets enqueued in the different files of the parent theme. This is different from one theme to another. There can be only one stylesheet, but usually there are many. The stylesheets can be enqueued in the functions.php file, but also in other files of the parent theme, like a second functions.php file located in the inc directory or like general.php or dashboard.php also located in the inc directory. You have to look for the functions wp_enqueue_style and wp_register_style to find the handles of the stylesheets that interest you and write those handles, separated by comma, in the array mentioned above.

For example, if you use the GeneratePress theme, you can search for wp_enqueue_style in all the files of the theme by running:

rgrep “wp_enqueue_style\|wp_register_style” /var/www/example.com/wp-content/themes/generatepress

The output of the command will indicate that the string wp_enqueue_style has been found in the following files:

/var/www/example.com/wp-content/themes/generatepress/inc/dashboard.php

/var/www/example.com/wp-content/themes/generatepress/inc/meta-box.php

/var/www/example.com/wp-content/themes/generatepress/inc/typography.php

/var/www/example.com/wp-content/themes/generatepress/inc/block-editor.php

/var/www/example.com/wp-content/themes/generatepress/inc/general.php

/var/www/example.com/wp-content/themes/generatepress/inc/customizer/controls/class-typography-control.php

/var/www/example.com/wp-content/themes/generatepress/inc/customizer/controls/class-range-control.php

/var/www/example.com/wp-content/themes/generatepress/inc/customizer/controls/class-upsell-section.php

If you look into each file listed above, you will find that the handles of the stylesheets that need to be enqueued in the child theme are the following: ‘parent-style’, ‘generate-style’, ‘generate-options’, ‘generate-comments’, ‘generate-widget-areas’, ‘generate-style-grid’, ‘generate-mobile-style’, ‘generate-font-icons’, ‘font-awesome’, ‘generate-rtl’, ‘generate-layout-metabox’, ‘generate-fonts’, ‘generate-block-editor-styles’, ‘generatepress-typography-selectWoo’, ‘generatepress-range-slider-css’, ‘generate-customizer-controls-css’.

This means that at the top of the functions.php file of the child theme for GeneratePress, you should have the following lines:

// Exit if accessed directly

if ( !defined( ‘ABSPATH’ ) ) exit;

// Enqueue parent theme styles

function theme_enqueue_styles() {

wp_enqueue_style( ‘parent-style’, get_template_directory_uri() . ‘/style.css’ );

wp_enqueue_style( ‘child-style’, get_stylesheet_directory_uri() . ‘/style.css’, array(‘parent-style’, ‘generate-style’, ‘generate-options’, ‘generate-comments’, ‘generate-widget-areas’, ‘generate-style-grid’, ‘generate-mobile-style’, ‘generate-font-icons’, ‘font-awesome’, ‘generate-rtl’, ‘generate-layout-metabox’,’generate-fonts’, ‘generate-block-editor-styles’, ‘generatepress-typography-selectWoo’, ‘generatepress-range-slider-css’, ‘generate-customizer-controls-css’ ));

}

add_action( ‘wp_enqueue_scripts’, ‘theme_enqueue_styles’ );

15.8.1.4.1. Dequeue Google fonts

Now is the moment to dequeue the Google fonts. Almost all WordPress themes enqueue Google fonts, so that each visitor’s browser makes a request to googleapis.com

or fonts.gstatic.com, in order to download the fonts and then display the web pages that use them. This is obviously very detrimental to the page loading speed and even more to digital privacy. Why should a third party know when your site is visited and by whom ? Obviously, this implies that you don’t use Google Analytics (and use Matomo instead). It’s incredible how this terrible practice of using Google fonts that get downloaded on the visitor’s browser each time a page is visited, has become so widely used.

First search for googleapis and gstatic in all the files of your theme, with the following command:

rgrep “googleapis\|gstatic” /var/www/example.com/wp-content/themes/generatepress

Replace example.com with your domain and generatepress with the name of your theme. You want to find the Google fonts URL, which is included inside the wp_enqueue_style or wp_register_style function. You will need the first parameter of this function, which is the handle.

For example, for the GeneratePress theme you will find the fonts.googleapis.com URL in the /var/www/example.com/wp-content/themes/generatepress/inc/typography.php file, in the following lines:

$fonts_url = add_query_arg( $font_args, ‘//fonts.googleapis.com/css’ );

if ( $google_fonts ) {

wp_enqueue_style( ‘generate-fonts‘, $fonts_url, array(), null, ‘all’ ); // phpcs:ignore

}

As you can see, the first parameter of the wp_enqueue_style function, which is the handle, is generate-fonts. To dequeue Google fonts from the child theme, in this case, you will have to first remove generate-fonts from the list of styles enqueued in the child theme that we mentioned earlier, then add the dequeue action. Therefore, the lines for enqueuing the styles of the parent theme and then dequeuing the Google fonts for GeneratePress should look like this:

// Exit if accessed directly

if ( !defined( ‘ABSPATH’ ) ) exit;

// Enqueue parent theme styles

function theme_enqueue_styles() {

wp_enqueue_style( ‘parent-style’, get_template_directory_uri() . ‘/style.css’ );

wp_enqueue_style( ‘child-style’, get_stylesheet_directory_uri() . ‘/style.css’, array(‘parent-style’, ‘generate-style’, ‘generate-options’, ‘generate-comments’, ‘generate-widget-areas’, ‘generate-style-grid’, ‘generate-mobile-style’, ‘generate-font-icons’, ‘font-awesome’, ‘generate-rtl’, ‘generate-layout-metabox’, ‘generate-block-editor-styles’, ‘generatepress-typography-selectWoo’, ‘generatepress-range-slider-css’, ‘generate-customizer-controls-css’ ));

}

add_action( ‘wp_enqueue_scripts’, ‘theme_enqueue_styles’ );

// Dequeue fonts

function dequeue_ggle_fonts() {

wp_dequeue_style(‘generate-fonts‘);

wp_deregister_style(‘generate-fonts‘);

}

add_action(‘wp_enqueue_scripts’, ‘dequeue_ggle_fonts’, 100);

15.8.1.4.2. Enqueue local fonts

After you dequeue Google fonts, you will want to replace them with the same fonts hosted on your server. Enqueuing locally hosted fonts is the best method of using fonts for web pages because it preserves control and privacy and it ensures that the fonts are downloaded fast along with the other web page files when the page is requested by a visitor’s browser.

First of all decide which fonts you want to use on your website. Le’t say that you want to use three fonts on your web pages: Inter, Source Sans Pro and Cousine. These are free and open source fonts published under the SIL Open Font License, Version 1.1 (Inter and Source Sans Pro) and Apache License v2.00 (Cousine). Unfortunately, many popular fonts are published under restrictive, proprietary licenses and they also cost. Why would you buy and use proprietary fonts, when there are good looking gratis free and open source fonts that look very similar to popular proprietary fonts ? For example, Inter, Source Sans Pro and Cousine look very similar to Arial, Proxima Nova and Courier New, respectively. Cousine is even metrically compatible with Courier New.

You should be aware that there are many websites that offer freely downloadable fonts that are not accompanied by their license. In many cases, these fonts are proprietary fonts and the font websites don’t warn you that you have to buy a license in order to legally use them. If you use such fonts without buying them, you can have legal problems. This is why it’s recommended to download and use only fonts that are accompanied by a license that guarantees they are free and open source fonts. The best websites where you can find free and open source fonts are: www.1001fonts.com and www.fontsquirrel.com. (You will have to read the license, because some fonts offered by these two websites can be free to use only for personal purposes, so, they are not truly free and open source fonts.)

After you download the preferred fonts from the mentioned websites, you will notice that you have only the ttf (True Type Font) or the otf (Open Type Font) files of the font. To properly add locally hosted fonts to web pages, you will need only two formats, which are supported by all modern browsers: woff (Web Open Font Format) and woff2 (Web Open Font Format 2.0). In the past, when some browsers didn’t support the woff and woff2 formats, you had to host the fonts in all these formats: ttf, otf, eot (Embeded Open Type), woff, woff2 and svg (Scalable Vector Graphics). This is not needed anymore because all modern browsers support the woff and woff2 formats. (If you want to drop support for Internet Explorer, you can even renounce to woff and use only woff2.) To obtain the woff and woff2 font files you can use the font converter (Webfont Generator) offered by fontsquirrel.com here. Upload your font file by clicking on ‘Upload Fonts’, check ‘Expert’, then, in the ‘Truetype Hinting’ section check ‘Keep Existing’, in the ‘Vertical Metrics’ section check ‘No Adjustment’, in the ‘Advanced Options’ section, in the ‘Font Name Suffix’ box remove the default ‘-webfont’ suffix, then leave all the other options as they are, scroll down and check ‘Yes, the fonts I’m uploading are legally eligible for web embedding.’, then click the ‘Download Your Kit’ button. To keep things simple, you may want to convert to the woff and woff2 formats only the font variations that you use. If you use only the regular, bold, italic and bolditalic variations, you may want to convert only the ttf/otf files for these variations and renounce the black, black italic, light, light italic, medium, medium italic, thin, thin italic, etc. variations.

After you have obtained your woff and woff2 files for the needed variations of each font that you want to use, you have to upload them to the ‘fonts’ directory, in your child theme. First create this directory:

mkdir /var/www/example.com/wp-content/themes/parentthemename-child/fonts

Then upload all the woff and woff2 files of all the fonts to the newly created fonts directory (Eg: by using FTP).

The next step is to add the locally hosted fonts to the style.css file of your child theme. Open this file:

nano /var/www/example.com/wp-content/themes/parentthemename-child/style.css

You should add @font-face blocks similar to the ones from below, at the top of the style.css file, right beneath the commented out section specifying the Theme Name, Template, Author, Description and Version. We exemplify with adding the regular, italic, bold and bold italic variations of the Inter font (change Inter to your own font). Please note that the path to the font files is relative to the style.css file of the child theme:

@font-face {
    font-family: 'Inter';
    src: url('../fonts/inter/Inter-Regular.woff2') format('woff2'),
         url('../fonts/inter/Inter-Regular.woff') format('woff');
    font-weight: 400;
    font-style: normal;
}

@font-face {
    font-family: 'Inter';
    src: url('../fonts/inter/Inter-Italic.woff2') format('woff2'),
         url('../fonts/inter/Inter-Italic.woff') format('woff');
    font-weight: 400;
    font-style: italic;
}

@font-face {
    font-family: 'Inter';
    src: url('../fonts/inter/Inter-Bold.woff2') format('woff2'),
         url('../fonts/inter/Inter-Bold.woff') format('woff');
    font-weight: 700;
    font-style: normal;
}

@font-face {
    font-family: 'Inter';
    src: url('../fonts/inter/Inter-BoldItalic.woff2') format('woff2'),
         url('../fonts/inter/Inter-BoldItalic.woff') format('woff');
    font-weight: 700;
    font-style: italic;
}

Please note that we didn’t include the local() parameter in the src directive, since trying to use the fonts installed on the visitor’s computer is not necessarily faster. You should ignore the settings suggested by fontsquirrel.com in the stylesheet.css file generated when converting the font, because using a different font-family for each font weight and font style is not optimal, for the reasons described in this article.

Then, to use the newly added font, you can use css settings like the ones listed below.

For italic:

.classname {
    font-family: 'Inter', sans-serif;
    font-weight: 400;
    font-style: italic;
}

For bold:

.classname {
    font-family: 'Inter', sans-serif;
    font-weight: 700;
    font-style: normal;
}

For bold italic:

.classname {
    font-family: 'Inter', sans-serif;
    font-weight: 700;
    font-style: italic;
}

Obviously, you can add other fallback fonts than sans-serif. Please note that if you want to set a font weight of 500 for the font used in the example from above, you will also need the woff2 and woff font files for the ‘medium’ variation of the font (a ‘medium’ font weight corresponds to a font weight of 500). Otherwise, when using the font weight of 500, the browser will render it as a full-fledged bold font, with a weight of 700.

Set ownership and permissions for all the font files:

cd /var/www/example.com/wp-content/themes/parentthemename-child
chown -R www-data:www-data fonts
chmod 750 fonts
chmod 640 fonts/*
15.8.1.4.3. Add an image to the child theme

You can also add an image to the child theme. This image will show up in the list of available themes, once you click Appearance > Themes. To add an image you can upload any image to the child theme’s folder and name it screenshot.png. The simplest way to add an image to the child theme is to copy the corresponding image from the parent theme.

15.8.1.4.4. Finish configuring the child theme

Change ownership and permissions for the child theme’s directory and its content:

cd /var/www/example.com/wp-content/themes
chown -R www-data:www-data parenttheme-child
chmod 750 parenttheme-child
cd parenttheme-child
chmod 640 *

Then, click Appearance > Themes, find your newly-created child theme and click ‘Activate’ to activate it.

From now on, you will apply all the code changes to the files inside the child theme directory, while leaving the original theme intact. When you have a child theme activated, WordPress applies all the changes found in the child theme to the parent theme.

To be more specific, when you want to add an action or a filter to the parent theme, so as to add a new functionality to your website, you will add it at the bottom of the functions.php file of the child theme instead. Also, when you want to change the visual aspect of your website by changing a css element from the main style.css file or other .css file of the parent theme, you will just write the modified version of that element in the style.css file of the child theme.

You can also change different files from the parent theme such as index.php, header.php, footer.php, page.php, comments.php, etc. These are files that don’t contain classes or custom functions and are called ‘template files’. To change these files you should first copy them to the child theme directory (/var/www/example.com/wp-content/themes/parenttheme-child) and then make any changes to their copies in the child theme. If you want to change a template file that is not located in the /var/www/example.com/wp-content/themes/parenttheme directory, but in a subdirectory of the parent theme’s directory, the general method is to create that subdirectory inside the child theme directory, then copy the original file there, then modify it. (For example, if you want to modify the /var/www/example.com/wp-content/themes/parenttheme/partials/page-header.php, you would copy it to /var/www/example.com/wp-content/themes/parenttheme-child/partials/page-header.php and then modify it there.) Please, be aware that only the files called with the get_template_part() function can be overwritten in this way.

A second method to overwrite code in the parent theme can be applied if a function in the parent theme is added with add_action() or add_filter(). In this case you can use the functions.php file in the child theme to first remove it with remove_action() or remove_filter(), and then replace it with your own function, added with add_action() or add_filter().

A third method can be used if the parent theme encapsulates a function or a class with an if statement that checks if the function exists (like: if (!function_exists(‘function_name’) or if (!class_exists(‘class_name’)) ). In this case you can overwrite that function/class by redefining it in the functions.php file of the child theme. To avoid possible conflicts with different plugins, you can also encapsulate your newly added function/class with an if statement, to check if the function/class exists, but this is not always necessary.

Please note that you don’t need to copy the content of the functions.php file from the parent theme, inside the functions.php file of the child theme. You only need to add your custom code at the bottom of the child theme’s functions.php file.

It’s important to understand that the css code from the style.css file from the child theme is applied by WordPress after the css code from the parent theme, so, it modifies all the css parameters with the same name inside the parent theme. However, things are different with the functions.php file. WordPress first runs the code inside the functions.php file of the child theme and afterwards, the code inside the parent theme. This is why it’s not very simple to overwrite functinos inside the parent theme using the child theme. However, such overwriting can be done using one of the three methods explained above. With other files such as header.php, footer.php, page.php, things are also different. If you copied any of those files inside the child theme, WordPress will run the files in the child theme and ignore the originals in the parent theme.

15.8.1.4.5. Disable the Gutenberg welcome popup window

One of the first things that you will want to do after creating a child theme is to disable the Gutenberg welcome window. When you will add new pages or posts, you will be annoyed by the Gutenberg welcome popup window which will appear again and again after removing your browser’s cookies. To disable this popup, add the following lines to the functions.php file of your child theme:

// Disable the unnecessary Gutenberg welcome popup window

function hide_gutenberg_welcome() {

?>

<script>

jQuery(window).load( function(){

wp.data && wp.data.select( ‘core/edit-post’ ).isFeatureActive( ‘welcomeGuide’ ) && wp.data.dispatch( ‘core/edit-post’ ).toggleFeature( ‘welcomeGuide’ );

});

</script>

<?php

}

add_action(‘admin_head’, ‘hide_gutenberg_welcome’);

If you also want to disable the default full-page view of the ‘New page’ window, add the following line below the line starting with wp.data from above:

wp.data && wp.data.select( ‘core/edit-post’ ).isFeatureActive( ‘fullscreenMode’ ) && wp.data.dispatch( ‘core/edit-post’ ).toggleFeature( ‘fullscreenMode’ );

15.8.1.5. Configure logrotate to rotate the WordPress access logs

Remember that the error logs configured in the /etc/nginx/sites-enabled/0-conf file were of the form:

error_log /var/log/nginx/example.com.error.log notice;

Since all the logs stored in the /var/log/nginx directory are configured in the /etc/logrotate.d/nginx file to be rotated automatically, there is no need to configure rotation for WordPress error logs. You only need to configure rotation for access logs which are of the form:

access_log /var/log/sites/example.com/access.log;

Open the /etc/logrotate.d/nginx file:

nano /etc/logrotate.d/nginx

Add the following block at the bottom of the file:

/var/log/sites/example.com/access.log {

missingok

rotate 10

daily

compress

delaycompress

create 0640 www-data adm

sharedscripts

prerotate

if [ -d /etc/logrotate.d/httpd-prerotate ]; then \

run-parts /etc/logrotate.d/httpd-prerotate; \

fi; \

endscript

postrotate

[ ! -f /var/run/nginx.pid ] || kill -USR1 `cat /var/run/nginx.pid`

endscript

}

You’ll have to add similar blocks for all the WordPress websites that you host on your server (www.secondsite.net, www.thirdsite.info, etc.).

The parameters have the following meaning:

  • missingok – If the log file is missing, go on to the next one without issuing an error message.
  • rotate 10 – Create a maximum of 10 archives.
  • daily – Rotate the logs once every 24 hours.
  • compress – Compress the old log files with gzip.
  • delaycompress– Postpone compression of the previous log file to the next rotation cycle. This way, at any moment there are two uncomplressed log files: the current log file and the previous one, while all the other log files are compressed.
  • create 0640 www-data adm– Create new log files as being owned by the user ‘www-data’ and the group ‘adm’, with the specified permissions.
  • sharedscripts– Run the postrotate script only once, after the old logs have been compressed, not once for each log which is rotated.
  • prerotate– The lines between ‘prerotate’ and ‘endscript’ are executed before the current log file is rotated. The if [ -d /etc/logrotate.d/httpd-prerotate ]; then \ command will run all the scripts in the /etc/logrotate.d/httpd-prerotate directory, if that directory exists and has scripts in it.
    postrotate – The `postrotate` command tells Nginx to reload the log file once the rotation is complete. It’s used after logrotate moves the old files, so that Nginx starts writing to the new log file.

This is only an example of a functional configuration and these parameters can be changed to meet specific needs.

For other applications except WordPress sites, it’s recommended to replace the daily directive with size 2M, which will rotate the logs only when they will reach 2MB in size. In this way, the uncompressed access logs will grow much larger before being rotated and compressed, therefore they will contain more data available to be analyzed automatically by other applications such as ‘System Health and Security Probe’ and ‘Fail2ban’. We would have set up a similar rotation rule for WordPress access logs, but in order to allow Matomo to use the log analytics method, we have to rotate the logs daily (Matomo is the traffic monitoring application whose installation is described further down below). Also, for other types of logs, the configuration block in the /etc/logrotate.d/nginx file may contain the notifempty directive placed right below the delaycompress directive, to avoid rotation of empty logs, but for websites monitored with Matomo, it’s important to have the logs rotated daily, even if they are empty, so the notifempty directive shouldn’t be present in the configuration block, as shown above.

You can also run ‘logrotate’ manually for a specific configuration file located in the /etc/logrotate.d directory. To run ‘logrotate’ for all the logs configured in the /etc/logrotate.d/nginx file manually, run:

logrotate -fv /etc/logrotate.d/nginx

15.8.2. Install the recommended plugins

15.8.2.1. Install the ‘WPS Hide Login’ plugin

As mentioned before, one of the best measures that you can take to protect your website from brute-force attacks is to change the default name of the log in page from wp-login.php to a difficult to guess custom name. In this way, if an attacker wants to guess your username and password by trying to log in with various combinations of usernames and passwords, they’ll first have to overcome the obstacle of finding the website’s login URL. The most elegant and time-efficient way to change the default name of the login page is by installing a light-weight and time-tested plugin called WPS Hide Login.

Go to ‘Plugins’ and delete the ‘Akismet Anti-Spam‘ and ‘Hello Dolly’ plugins because ‘Hello Dolly’ is useless and ‘Akismet Anti-Spam‘ can be replaced with other better anti-spam gratis plugins, such as ‘Stop Spammers’. Next, click on ‘Add New’ to install a new plugin, search for WPS Hide Login. Once you find it in the list of available plugins click ‘Install Now’ on its tile, then click ‘Activate’. Then, on the left panel click on ‘Plugins’ and under WPS Hide Login click on ‘Settings’. You will be sent to the bottom of the ‘General Settings’ page where you will find a section named ‘WPS Hide Login’ with two fields that you can customize: ‘Login url’ and ‘Redirection url’. In the ‘Login url’ enter the custom name of the login page. It should be a name that you could easily remember, but hard to guess for an attacker. Then click ‘Save Changes’.

15.8.2.2. Upgrading the ‘WPS Hide Login’ plugin

You can upgrade WPS Hide Login like any other WordPress plugin: when you see an ‘update now’ link next to the name of this plugin on the ‘Plugins’ page, click that link and wait for the update to complete. The process will preserve the custom login URL that you set up at the beginning, so all you have to do is to verify if it works as it should by logging out, then accessing the custom login URL and logging in again.

15.18.2.3. Install the ‘Nginx Cache’ plugin

In order to purge the Nginx cache when a web page is edited, so that the visitors can see the updated version of the page, you’ll have to install the Nginx Cache plugin. After you install and activate it like you did with the previousplugin, on the ‘Plugins’ page, under its name, click on ‘Settings’ and then, on the plugin’s settings page, in the ‘Cache Zone Path’ field enter the path to the Nginx FastCGI cache: /var/cache/nginx . Then check ‘Automatically flush the cache when content changes’, then click ‘Save Changes’.

You can also use the ‘Purge Cache’ button on the plugin’s settings page to remove all the content of the FastCGI cache.

15.8.2.4. Upgrading the ‘Nginx Cache’ plugin

Nginx Cachecan be updated like any other WordPress plugin, by clicking the ‘update now’ link next to its name, on the ‘Plugins’ page, when there is an update notification there. The update process will preserve the initial settings.

15.8.2.5. Install the ‘WooCommerce’ plugin

Obviously, WooCommerce is a recommended plugin only if you want to add e-commerce functionality to your website. Otherwise, there is no need to install it and you can skip this chapter.

On the left panel click ‘Plugins’, then click the ‘Add new’ button, then use the search box to search for WooCommerce. Once you find it click ‘Install Now’, then ‘Activate’. Once you installed and activated WooCommerce, close the configuration wizard because it’s recommended to configure WooCommerce manually by clicking ‘WooCommerce’ on the left panel, then clicking ‘Settings’.

In the ‘Settings’ section you will see a row of tabs: General, Products, Tax, Shipping, Payments, Accounts & Privacy, Emails, Integration, Advanced. You should check each of these tabs and each of their sub-tabs, to see if the default settings are convenient or you need to change them. Each e-commerce store has its own specifics, so you should choose the settings that fit your particular situation. However, you’ll have to make the following two settings in WooCommerce, in order to allow your e-commerce site to properly communicate with Dolibarr (Dolibarr is a Client Relationship Management (CRM) and Enterprise Resource Planning (ERP) application; we’ll explain how to install it further down below):

  • On the left panel click ‘WooCommerce’ > ‘Settings’, then on the ‘General’ tab scroll down and make sure you have 2 decimals in the ‘Number of decimals’ number field. Click ‘Save changes’.
  • Then, on the ‘Tax’ tab, under ‘Tax options’ at ‘Prices entered with tax’ select ‘No, I will enter prices exclusive of tax’. In this way, when you add a product in WooCommerce, the sale price that you set for that product will be the price exclusive of tax.

We can also recommend the following settings which you should make, unless you have strong reasons not to:

On the General tab:

  • in ‘Default customer location’ you should choose ‘Geolocate’. In this way, visitors will see taxes and shipping costs adjusted according to their geographic location during checkout.
  • unless your business/nonprofit organization doesn’t pay taxes for the products/services that you sell, in ‘Enable taxes’ check ‘Enable tax rates and calculations’.

On the Products tab:

On the General sub-tab:

  • in ‘Weight unit’ choose the weight unit that you will be using to define weight for all the products that you will set up in your e-commerce store.
  • in ‘Dimensions unit’ choose the length unit that you will be using to define length for all the products that you will set up in your e-commerce store.

If you have to pay taxes, you’ll have to configure taxes on the Tax tab. You probably know that sales taxes depend on the country/state/province of the seller, but also on the country/state/province of the buyer.

On the Tax tab:

On the Tax options sub-tab:

  • in ‘Calculate tax based on’ choose ‘Customer billing address’.
  • in ‘Display tax totals’ choose ‘Itemized’.
  • in the ‘Additional tax classes’ text box enter the names of the tax classes that you will be using (taking into consideration the types of products that you will sell, your location and your potential customers’ location), one per line, then click ‘Save changes’. After you save the tax classes you will notice that all of them are displayed as sub-tabs of the Tax tab, next to ‘Tax options’.

Click on each tax class sub-tab, next to ‘Tax options’, and enter the data needed to properly set up tax

classes (in each tax class you can have multiple tax rates):

  • in the ‘Country code‘ field enter the two letter country code (eg.: US, FR etc.) of the country of your customers for that particular class.
  • in the ‘State code‘ field you can enter the two letter code of a state. You can leave the field empty, to apply the tax rate to all the states in the selected country.
  • in the ‘Postcode / ZIP‘ field you can enter the postal code of the region in the selected country, for which the tax rate will apply. You can leave the field empty to apply the tax rate to all the ZIP codes.
  • in the ‘City‘ field you can enter the city from the selected country, for which the tax rate will apply. You can leave the field empty to apply the tax rate to all the cities.
  • in the ‘Rate %‘ field enter the tax rate in the form of a percentage number, with four decimal places (eg.: 16.7500; this would mean 16.75 %).
  • in the ‘Tax name‘ field enter the name of the tax. This name is important, because you will use it to recognize the tax in WooCommerce, as well as in Dolibarr. It’s important to enter a suggestive name that will contain both the abbreviated name of the tax and the percentage. For example: SalesTax – 5.75%, or VAT – 21%.
  • in the ‘Priority‘ field enter the number representing the priority of the tax rate. This field is intended to be used for regions with certain tax rates that include specific regions with different tax rates. For example: you enter one tax rate for all the US states, one tax rate for the specific state of California, and one tax rate for a specific ZIP code inside California. To allow WooCommerce to properly select the tax rate for a product included in the current tax class, when you sell it to an American resident, you will have to enter the different tax rates with the following priorities:

As you can notice, the priority for all the US states is 1, the priority for California is also 1 and for the 90210 ZIP code inside California the priority is 2. In this way, when you sell a product, for which you assigned this tax class, to an American resident, WooCommerce will look at the customer’s address determined by geolocation from their IP address (if you chose ‘Geolocate’ at ‘Default customer location’ on the ‘General’ tab), to choose the applicable tax rate: if the customer’s country is US, WooCommerce will look first at the tax rates from US with priority 1: if the customer is from a different state than California, WooCommerce will choose the tax rate for all the US states (6%), if the customer is from California, WooCommerce will first apply the tax rate with priority 1 from California, then it will check if the tax rate with priority 2 from California can be also applied, that is if the customer is from the 90210 ZIP code inside California: if yes, the tax rate for the 90210 ZIP code (10%) will be also added to the price of the product.

  • check the ‘Compound‘ checkbox for every tax rate which has to be applied on top of the other applicable tax rate(s). In the example from above, if the tax rate for the 90210 ZIP code has the ‘compound’ attribute, it will not be applied directly to the price of the product, but to the price of the product plus the general tax for California.
  • check the ‘Shipping‘ checkbox for every tax rate which is also applicable to shipping. If an order contains products with different tax rates, the shipping tax will be applied as follows:
  • If there is a product with the default ‘Standard’ tax rate in the order, its tax rate will be applied to shipping;
  • If there is no product with the default ‘Standard’ tax rate in the order, the first rate found in the ‘Additional tax classes’ section on the ‘Tax options’ sub-tab, will be applied to shipping.

If you sell physical products that have to be shipped to the customers, you’ll also have to configure shipping.

On the Shipping tab:

On the Shipping classes sub-tab:

  • Shipping classes can be used to group products of similar type and can be used to apply different shipping costs, as we’ll explain below. To add a shipping class click ‘Add shipping class’, then in the ‘Shipping class name’ field enter a suggestive name, for example ‘Large Volume’, or ‘Small Volume’, etc., in the ‘Slug’ field enter the class’ slug, for example large-volume, in the ‘Description’ field enter a short description, such as ‘Packages exceeding 1 cubic meter in volume’, then click ‘Save shipping classes’.

On the Shipping zones sub-tab:

  • As the explanatory note states: “A shipping zone is a geographic region where a certain set of shipping methods are offered. WooCommerce will match a customer to a single zone using their shipping address and present the shipping methods within that zone to them.” Click ‘Add shipping zone’ to add a shipping zone, then in the ‘Zone name’ field enter a suggestive name for the shipping zone, in the ‘Zone regions’ field enter one after the other the countries or regions within countries that belong to that shipping zone, or the ZIP codes, one per line, that belong to that zone, then under ‘Shipping methods’ click ‘Add shipping method’; you can then select between ‘Flat rate’, ‘Free shipping’ and ‘Local pickup’. These three methods can be enough for your commercial situation, so you can add one or more of these methods and then click ‘Save changes’. However, if you need other shipping methods, such as custom methods that would cost more for large volume/weight products and less for small volume/weight products, or shipping methods that would show as available on the checkout page based on the number of products in the cart, or on the total weight of the products in the cart, etc., you can install the gratis ‘Custom Shipping Methods for WooCommerce‘ plugin. This plugin will add a fourth option called ‘Custom shipping’ to the three methods mentioned above, so that you can configure custom shipping methods based on different factors. After you choose one of the shipping methods from the drop-down list, click ‘Add shipping method’, then click on the name of the newly added shipping method to edit it. For example, if you added ‘Flat rate’ as a shipping method and you click on its name, you will see a new window with a ‘Method title’ field for the method title, a ‘Tax status’ field where you can specify if the shipping done with this method is taxable or not, a ‘Cost’ field where you can enter an amount of money that would be the cost of the shipping for this method. You can also set up different costs for this shipping method based on the shipping classes of the ordered products. In the previous paragraph we explained how to set up shipping classes. You will see a list of cost fields for all the shipping classes available. In these cost fields you can enter a number, but also a formula. For example, if you want to add a shipping cost of 20% of the total cost of the order for a certain shipping class, you would enter [cost] * 0.20, where [cost] represents the total cost of the order. If you want to add a cost of $5.75 for every product in a shipping class, you would enter [qty] * 5.75, where [qty] stands for the number of items and 5.75 is the shipping cost for each of them. In the ‘Calculation type’ field you can choose to charge shipping for each shipping class individually or to charge shipping for the most expensive shipping class. When you finish click ‘Save changes’. If you installed the ‘Custom Shipping Methods for WooCommerce‘ plugin, you can set up the ‘Custom shipping’ method in a similar way.

On the Shipping options sub-tab:

  • In ‘Calculations’ it’s recommended to check ‘Hide shipping costs until an address is entered’.
  • In ‘Shipping destination’ select ‘Default to customer billing address’.

On the Payments tab you can configure the payment methods that you want to offer on your e-commerce website. The default methods that you can configure are: ‘Direct bank transfer’, ‘Check payments’, ‘Cash on delivery’ and ‘PayPal’.

On the Accounts & Privacy tab, under ‘Privacy Policy’, in ‘Privacy page’, you can select one of your website’s pages as your ‘Privacy Policy’ page. This is important for legal compliance. Also, under ‘Personal data retention’, you can set the time interval for which you want to retain inactive customer accounts and orders.

On the Emails tab you can select for which WooCommerce events (such as ‘new order’ or ‘new customer account’) you want to receive an email notification at the administration email address set up when installing WordPress.

On the Advanced tab, on the Page setup sub-tab, you can set up different pages of your website as ‘Cart page’, ‘Checkout page’, ‘My account page’, ‘Terms and conditions’ page and you can change the default endpoints of certain URLs. On the REST API sub-tab you can add keys that you can use in other applications to connect to the WordPress API. On the Webhooks sub-tab you can configure WooCommerce to send event notifications to URLs of other applications or services.

15.8.2.5.1. How to enable geolocation in WooCommerce

As mentioned above, if you sell products or services to customers outside your country/state/province, you will want to enable geolocation in WooCommerce, so that you can show your customers different taxes and shipping costs based on their automatically detected location. This customized taxes and shipping costs should appear on the cart and checkout pages, which should never be cached.

To enable the ‘Geolocate’ option in WooCommerce, you have 2 options:

1) You use the MaxMind geolocation database (GeoLite2) after you open an account on their website (https://www.maxmind.com/en/geolite2/signup) where you will have to enter your name, email, company name, industry of your company, country, intended use; after you sign up, you will have to generate a ‘license key’ and enter that key in WooCommerce > Settings, on the ‘Integration’ tab, in the ‘MaxMind License Key’ field. All the process is detailed on this WooCommerce documentation webpage. The truth is that WooCommerce uses the ‘license key’ only when it downloads the new database version from the MaxMind’s website. The most important thing on the ‘Integration’ tab is the ‘Database File Path’ which can be changed to a custom path and a custom database name, as we’ll describe below.

2) If you are tired of signing up and giving your personal/business details to third parties to be able to use services that should be freely available (as MaxMind databases were, until a while ago, when they decided to ask their users to sign up), you can use the gratis and freely accessible ‘IP to Country Lite database’ offerred by db-ip.com here . As you can see, they offer the database in the mmdb format, which is the ‘MaxMind Database Format’, so, once you download the database and place it on your server, it can be used by WooCommerce just like the MaxMind’s ‘GeoLite2-Country.mmdb’ database.

If you choose the second option, which we recommend, you will want to change the default, hardcoded name of the MaxMind’s database, in WooCommerce. To achieve this, add the following lines at the bottom of the functions.php file, inside your active child theme:

// Change the default path and name for the geolocation database

function woocommerce_new_geolocation_database_path( $database_path ) {

$upload_dir = wp_upload_dir();

$database_path = trailingslashit( $upload_dir[‘basedir’] ) . ‘woocommerce_uploads/dbip-country-lite.mmdb’;

return $database_path;

}

add_filter( ‘woocommerce_maxmind_geolocation_database_path’, ‘woocommerce_new_geolocation_database_path’ );

Next, if you go to WooCommerce > Settings > Integration, you will see the new database name instead of the default name. We didn’t actually change the path, but if you want to place the new database in a different directory, you can change the path too in the $database_path variable from above. So, according to the new settings, the database will be called dbip-country-lite.mmdb and it will be placed in the /var/www/example.com/wp-content/uploads/woocommerce_uploads directory.

db-ip.com updates the ‘IP to Country Lite’ database once a month, usually on the first day of every month. Since it’s important to always have an updated geolocation database on your server, you will want to write a script that will automatically download the archived database, uncompress it and save it in the right location, once a month. To do so, run the following commands:

nano /srv/scripts/download-dbip-country-lite

Enter the following content inside this file:

#!/bin/bash

wget –tries=3 -O dbip-country-lite.mmdb.gz https://download.db-ip.com/free/dbip-country-lite-$(date +%Y)-$(date +%m).mmdb.gz

gunzip -c dbip-country-lite.mmdb.gz > /var/www/example.com/wp-content/uploads/woocommerce_uploads/dbip-country-lite.mmdb

gunzip -c dbip-country-lite.mmdb.gz > /var/www/secondsite.net/wp-content/uploads/woocommerce_uploads/dbip-country-lite.mmdb

rm dbip-country-lite.mmdb.gz

Change ownership and permissions for this script:

chown root:root /srv/scripts/download-dbip-country-lite

chmod 700 /srv/scripts/download-dbip-country-lite

At the bottom of the script you can add lines like this:

gunzip -c dbip-country-lite.mmdb.gz > /var/www/secondsite.net/wp-content/uploads/woocommerce_uploads/dbip-country-lite.mmdb

for every WordPress website for which you want to enable WooCommerce geolocation.

The script will download the archived database from the specified URL (retrying 2 times if the download fails the first time), it will rename the downloaded archive to dbip-country-lite.mmdb.gz, it will extract it to the proper location for every path mentioned, then it will remove the archive from the /srv/scripts directory.

To run the script automatically once a month, create a cron job:

crontab -e

Add the following lines at the bottom of this file:

# Download the dbip-country-lite database used for geolocation, on the 3rd day of every month, at 4:41 AM

41 4 3 * * /srv/scripts/download-dbip-country-lite

There remains only one problem: since in ‘WooCommerce’ > ‘Settings’ > ‘General’ > ‘Default customer location’, you chose the ‘Geolocate’ option, WordPress will try to update the MaxMind database by downloading a new version once every week. Since you already set up a cron job to update the geolocation database, you don’t need WordPress to try to download anything, so you have to remove the scheduled update, by adding the following lines in the functions.php file of your active child theme:

//Removing the cron jobs associated with the ‘woocommerce_geoip_updater’ hook

function remove_cron_jobs() {

wp_clear_scheduled_hook(“woocommerce_geoip_updater”);

}

add_action(“init”, “remove_cron_jobs”);

That’s it. The ‘IP to Country Lite’ database has been tested and it is enough to detect the country of each visitor but you can experiment using the ‘IP to City Lite’ database instead, since it can detect the location of the visitors up to the city level.

15.8.2.6. Upgrading the ‘WooCommerce’ plugin

Before upgrading WooCommerce to a new version, it’s recommended to check if the new version has been tested and confirmed to function well within the suite by visiting this page.

If you click on ‘Plugins’ on the left panel and on the ‘Plugins’ page you notice an ‘update now’ link next to WooCommerce, with a note specifying that the current update is a major update, it’s recommended to first backup the entire database of the website (using phpMyAdmin) as well as the files directory (by connecting through SSH to your remote server and running: tar czf example.com-2021-03-12.tar.gz /var/www/example.com , where example.com is the domain of your website and 2021-08-12 is the date of the backup); then perform the update of WooCommerce by clicking on the ‘update now’ link. If the update is not a major one you can perform it in the same manner but in general you only need to backup the database and files before major updates. Usually, after a WooCommerce update, you will be notified that you also need to perform a ‘database update’ by clicking a button displayed in the upper part of the ‘Plugins’ page. Click that button and wait until the database update is completed. After the database update finishes, it’s recommended to check if the website looks and works as expected, if products can be put into the shopping cart, if the checkout process works as it should, etc. Since an update can break some functionalities of the website or change its appearance, the most professional way of updating WooCommerce, as well as any other plugin, is to first apply the update on a ‘development’ version of the website, installed for testing purposes on the dev.example.com subdomain, as we’ll explain later. Only when you make sure that the update succeeds on the testing version of the website, you apply it on the ‘live’ version.

The update process will preserve the WooCommerce settings that you made before starting the update.

15.8.2.7. Install the ‘Sync WooCommerce with Dolibarr’ plugin

‘Sync WooCommerce with Dolibarr’ is a WordPress plugin that acts like a bridge between WooCommerce and Dolibarr ERP/CRM. When a product is created in WooCommerce, it is automatically transferred to Dolibarr (along with its data and product categories). If an order is placed by a client in WooCommerce, the order is automatically transferred to Dolibarr (along with all the client’s data). This plugin also allows the admin to manually transfer products/orders/clients/registered users in bulk to Dolibarr. It offers an option to apply structured SKU numbers to all the products in the store. It also adds to the ‘Inventory’ section of products a ‘Generate SKU number’ button, a ‘Unit of measure’ field, where the admin can choose a unit of measure for the product, options to display the unit of measure on the shop page or on the single product page and cart page and a field for the warehouse name. Dolibarr has to be installed on the same machine as the WooCommerce website. This plugin is designed so that multiple WooCommerce websites can be connected to one Dolibarr instance, installed on the same machine.

‘Sync WooCommerce with Dolibarr’ can be installed like any other WordPress plugin: click on ‘Plugins’ > ‘Add New’, search for ‘Sync WooCommerce with Dolibarr’, click ‘Install Now’, then ‘Activate’. Next navigate to the plugin’s settings page: ‘WooCommerce’ > ‘Settings’, then click on the ‘WC-Dolibarr’ tab.

The first 5 fields on the settings page are mandatory. If you don’t fill them, the plugin won’t work:

– in the ‘Dolibarr database name’ field enter the name of the Dolibarr database as you set it up in phpMyAdmin when creating the Dolibarr database and user.

– in the ‘Dolibarr database user’ field enter the name of the Dolibarr database user.

– in the ‘Dolibarr database user password’ field enter the password of the Dolibarr database user.

– in the ‘Dolibarr database tables prefix’ field leave llx_ if you haven’t changed the default prefix of database tables when installing Dolibarr.

– in the ‘Dolibarr product pictures directory’ field enter the default location of the product pictures directory, /var/www/doli.example.com/documents/produit, if you haven’t changed it to something else.

After you fill in all the mandatory fields click ‘Save’ at the bottom of the page.

Below the mandatory fields are several optional fields. You can use them if you need them:

– the ‘Set a custom SKU prefix’ field allows the admin to set a custom prefix made up of alphanumeric characters, for the SKU numbers assigned to all the products in the store. If the field is left empty, the first three letters of the website’s domain or subdomain will be used as SKU prefix. If you set a SKU prefix, you’ll have to click on ‘Save’ afterwards, and if you want to apply the new SKU prefix to all the products in bulk, use the ‘Bulk set products SKU numbers’ option.

– the ‘Bulk set products SKU numbers’ option allows the admin to apply the default SKU number or the SKU number with a custom prefix to a range of products or to all the produts in the store. The SKU’s will be changed only in WooCommerce. To change them also in Dolibarr you’ll have to export the products in bulk to Dolibarr using the ‘Bulk export products to Dolibarr’ option.

– the ‘Bulk export products to Dolibarr’ option allows the admin to export products to Dolibarr in bulk: either the most recent n products, where n is the number given in the text box, or all the products in the WooCommerce store.

– the ‘Don’t export product to Dolibarr when it is saved in WooCommerce’ checkbox can be checked if you don’t want products to be exported automatically to Dolibarr when they are saved individually in WooCommerce.

– the ‘Don’t remove product from Dolibarr when it is removed in WooCommerce’ checkbox can be checked if you don’t want products to be removed automatically from Dolibarr when they are removed in WooCommerce.

– the ‘Bulk export clients to Dolibarr’ field allows the admin to export in bulk to Dolibarr the clients who sent all the orders received in WooCommerce, or just the clients who sent the last n orders, where n is the number entered in the text box.

– the ‘Bulk export registered users to Dolibarr’ option allows the admin to export in bulk to Dolibarr the registered users from a certain category (subscriber, contributor, customer, etc.) or all the registered users.

– the ‘Bulk export orders to Dolibarr’ option allows the admin to export in bulk to Dolibarr all the orders received in WooCommerce, or the last n orders, where n is the number entered in the text box.

– the ‘Don’t export order to Dolibarr when it is received in WooCommerce’ checkbox can be checked if you don’t want orders received in WooCommerce to be automatically exported to Dolibarr.

If you fill in the ‘Set a custom SKU prefix’ field or you check any of the checkbox options, you have to click on the ‘Save’ button from the bottom of the page in order to have the modifications saved in the database.

This plugin adds on the ‘Inventory’ section of each product a ‘Generate SKU number’ button that can be used to generate the standard SKU number for that respective product, a ‘Unit of measure’ field where the appropriate unit of measure can be chosen from a drop-down list, a checkbox that allows showing the unit of measure on the shop page, a checkbox for showing the unit of measure on the single product page and on cart page, and a field for the name of the Dolibarr warehouse where that product is stored.

To be able to properly use this plugin, you will have to take into consideration the following details. Please read them carefully:

  • This plugin needs WordPress Version 5+, WooCommerce Version 3.2.6+ and Dolibarr 12+.
  • The web server’s user (www-data) must have read and write access to all the directories inside the plugin’s directory.
  • To use the bulk export options available on the plugin’s settings tab, the user must be logged in as administrator or editor.
  • To avoid having differences between the WooCommerce orders’ total price and the corresponding Dolibarr orders, you will have to enter product prices without tax whenediting products. So, go to WooCommerce Settings > Tax > Prices entered with tax, and check ‘No, I will enter prices exclusive of tax’. Also, in ‘Display prices during cart and checkout’ it’s very important to select ‘Excluding tax’, otherwise there will be differences between the orders’ total price in WooCommerce and the total price of the corresponding Dolibarr invoices.
  • You should set the decimal precision both in WooCommerce and Dolibarr. The plugin is intended for use with prices with a maximum precision of 2 decimal places, so set the number of decimals in WooCommerce to 2 (WooCommerce > Settings > General > ‘Number of decimals’). In Dolibarr, go to Home > Setup > Limits and accuracy and set 8 in ‘Max. decimals for unit prices’, 2 in ‘Max. decimals for total prices’ and 2 in ‘Max. decimals for prices shown on screen’.
  • In Dolibarr, to hide product description on order and invoice lines, go to Home > Setup > PDF > scroll down to the ‘Other’ section, then next to ‘Hide products description’ select ‘Yes’, then click ‘Save’. If you don’t hide product description on order and invoice lines, all the text from the product’s description from WooCommerce will be added on the order/invoice line, next to the product name, which will make the order or invoice almost unreadable.
  • Before receiving any orders in WooCommerce, make sure that all the products from WooCommerce are already transferred to Dolibarr (you can use the ‘Bulk export products to Dolibarr’ option from ‘Sync WooCommerce with Dolibarr’ Settings, to transfer all the products from WooCommerce to Dolibarr at once).
  • In Dolibarr, the ‘Stocks’ module has to be enabled and in the ‘Stocks’ settings, the option ‘Decrease real stocks on shipping validation’ has to be enabled.
  • If you live in a country where by default, only one type of sales tax is applicable, you will have only the first tax enabled by default in Dolibarr (Home > Setup > Company/Organization > ‘Type of sales tax’). In this situation, if you receive orders with two or three types of taxes, in order to have them properly converted into invoices, you’ll have to explicitly enable the second and the third tax in Dolibarr > Home > Setup > Company/Organization > scroll down to ‘Type of sales tax’ and check ‘Use second tax’ and if a third tax appears on the orders, also check ‘Use third tax’. All the taxes included in the WooCommerce orders are automatically transferred to Dolibarr along with the orders, so you don’t have to enter the taxes manually in Dolibarr, but as explained, the second and the third tax have to be enabled by hand, if you receive orders containing a second and a third tax. You only need to enable them once.
  • The same currency needs to be set up in WooCommerce and in Dolibarr. That currency will be the one used by default in all the orders, invoices, etc. If on occasion, you need to issue invoices or other documents in other currency, first you will have to enable that currency in Dolibarr in Home > Setup > Modules/Applications > Multicurrency > click on the settings wheel > under ‘Currencies used’ select a new currency, enter the exchange rate in the ‘Rate’ field, then click on ‘ADD’. Then you’ll be able to choose the new currency for the occasional documents that you want to issue, while keeping the default currency as default and using it for all ordinary documents.
  • When adding a product to WooCommerce, the subcategory of the product should be checked, but also all the parent categories of the product’s subcategory, up to the parent category of all the products, which is usually called “All products”, or “All”. So, each product should be included either directly into “All products”, or into a unique subcategory of “All products”, let’s say, “Plastic products”, or to a unique subcategory of that subcategory, let’s say “Disposable plastic products”, etc. The idea is that a product should belong to only one unique subcategory, and before saving the product, you should check all the parent categories of that subcategory, up to the root category (on the right panel of the screen, in the ‘Product categories’ list). There can be also multiple root categories. In this case, the product should be included either directly into one of the root categories, or into a unique subcategory of one of the root categories. Each product should belong to one and only one category (and all its parent categories if it has any). If the product is included in multiple parallel categories (categories that are not included into one another), the plugin won’t transfer the product to Dolibarr.
  • Before saving a product in WooCommerce, don’t forget to add the SKU number by pressing the ‘Generate SKU number’ button under ‘Inventory’.
  • The plugin will not work in a WordPress multisite scenario (multiple WordPress websites managed from the same Dashboard). However it will work if installed on multiple separated WordPress websites connected to the same Dolibarr instance.
  • In WooCommerce, it’s recommended that the ‘Tax name’ be 10 characters in length at the most, because it will be used as tax code in Dolibarr, in a field with a maximum of 10 characters. If the length of the ‘Tax name’ exceeds 10 characters, it will be automatically trimmed to 10 characters for the corresponding tax code in Dolibarr.
  • This plugin has only been tested with languages using the Latin alphabet. If the alphabet used by the WooCommerce website is different from the Latin alphabet, the collation type set in the website’s MariaDB database has to be exactly the one specific to the language used. Otherwise the product categories won’t be properly ordered and exported to Dolibarr. For example, if the website’s language is Ukrainian, and the alphabet Ukrainian Cyrillic, the collation type set for the website’s MariaDB database should be ‘cp1251_ukrainian_ci’. If the right collation type hasn’t been set when the website’s database has been created, it can still be changed later, as explained in the WordPress Codex: https://codex.wordpress.org/Converting_Database_Character_Sets .
  • To be able to use this plugin, the product types must be understood and used consistently when adding products in WooCommerce:
  • First of all products can be simple products, variable products (like a shirt having variations of sizes and colors), and grouped products (like products that can be bought individually but can be also bought together, because they are somehow related, like different pieces of sport equipment: gloves, cap, trousers etc.). WooCommerce also has a fourth native type of products which is ‘affiliate’ products, but for purposes of connecting WooCommerce to Dolibarr, affiliate products should be ignored, because you don’t do product storage, inventory, etc. for affiliate products. You just recommend affiliate products on your website and others sell them.
  • All the products can be also safely cast into three categories: physical products (like t-shirts), true services (like giving piano lessons), and ‘improperly called services’, namely all the downloadable digital products (like software applications, e-books, pictures, music files, etc.) In WooCommerce, when adding physical products neither the “Virtual” option, nor the “Downloadable” option should be checked. For true services only the “Virtual” option should be checked and for ‘improperly called services’ both the “Virtual” and “Downloadable” options should be checked. The “Virtual” option only shows that the product is not meant to be shipped, so this option disables all the shipping fields which become unnecessary. Foreign language or math lessons may not be virtual when they are taught in person but the “Virtual” option should be checked to show that these products are services and that they don’t require shipping.
  • The downloadable digital products will appear in Dolibarr as “Services” and will have the “Downloadable” field on the product card marked with “Yes”.
  • When adding digital products recorded on a physical medium, such as DVD’s, those products become physical products which must be shipped etc., so neither the “Virtual” option, nor the “Downloadable” option should be checked in that case.
  • Since Dolibarr doesn’t have a native ‘grouped product’ type, to avoid unnecessary complications, grouped products are transferred to Dolibarr as separate individual products. On orders and invoices, grouped products would appear as individual products anyway, each with its own quantity and price. In WooCommerce grouped products are really independent of each other, because each of them can be sold independently and in any quantity. However, for variable products the situation is different. Dolibarr has native support for variable products, so, all WooCommerce variable products will be exported to Dolibarr as variable products, each with its own attributes, price, picture, etc.
  • If you want to transfer orders from WooCommerce to Dolibarr in bulk, make sure that the products on the old orders still exist in WooCommerce and in Dolibarr. Otherwise, the bulk transfer of orders to Dolibarr will fail. If the products on any orders don’t exist anymore in WooCommerce, to be able to transfer the orders that contain them, you’ll have to manually recreate those products in WooCommerce and set ‘Visibility’ for each of them as ‘Private’ (on the right sidebar in the WooCommerce product edit page). Also, in Dolibarr you will have to manually add those products and make them ‘Not for sale’ and ‘Not for purchase’ (by choosing ‘Not for sale’ and ‘Not for purchase’ respectively, from the Status drop-down lists, when adding the products).
  • If a simple product is removed in WooCommerce, it will also be automatically removed from Dolibarr, on the condition that it doesn’t appear on any customer or vendor order or invoice and it hasn’t been added to stock in Dolibarr. If the product is variable and is removed in WooCommerce, it will also be automatically removed from Dolibarr, along with all its variations. However, if a variation of a variable product is manually removed from WooCommerce, it has to be manually removed from Dolibarr, because individual variations are not removed automatically from Dolibarr when they are removed from WooCommerce.
  • When you delete a line from a WooCommerce order, it will be automatically deleted from the corresponding Dolibarr order. However, if you remove the shipping line(s) or a fee line or a coupon from a WooCommerce order, they won’t be automatically removed from the Dolibarr order, so you will have to manually remove them from the Dolibarr order.
  • In general, if you want to remove a product that already appears on WooCommerce orders that have been transferred to Dolibarr, invoiced, shipped, etc. it’s better to avoid deleting it in WooCommerce or in Dolibarr, since it appears on important documents and removing it will disrupt data records. In this situation, it’s recommended to unpublish the product in WooCommerce by changing ‘Visibility’ to ‘Private’ and to mark it in Dolibarr as ‘Not for sale’ and ‘Not for purchase’.
  • In order for this plugin to export products to Dolibarr correctly, the proper unit of measure has to be chosen when adding or updating a product (Inventory > Unit of measure). If no unit of measure is chosen, the product will be exported to Dolibarr with the default unit of measure, which is ‘piece’, shortened to ‘pc’.
  • This plugin can be installed in multiple WooCommerce websites connected to the same Dolibarr installation and database. If the same customer buys from two or more WooCommerce websites connected to the same Dolibarr installation, that customer will be exported to Dolibarr as one single customer. However, in the case of identical products sold on multiple websites connected to the same Dolibarr installation, the situation is different. If you have one product that is sold under the same name, price, description, etc. on two different websites connected to Dolibarr, it will be exported to Dolibarr as two distinct products, each with its own distinct SKU number, its own separate stock quantity, and its own ‘origin’ website displayed on the product card, although the name and other details of the product will be the same. If the same product sold on different WooCommerce websites was exported to Dolibarr as one single product, this would create serious discrepancies when someone would update the price or description of the product on one website, and those changes would be transferred to Dolibarr, while the same product on the other websites would have different details than the corresponding Dolibarr product.
  • If during the installation process of Dolibarr, you changed the default table prefix from llx_ to something else, you have to manually replace the llx_ prefix with the new one, in all the .sql files located in the ‘dolibarr root directory/custom/syncdolibarrwithwoocommerce/sql’ folder, before activating the ‘Sync Dolibarr with WooCommerce’ module in Dolibarr.
  • WooCommerce product images should be in one of the following formats: jpeg, jpg, png, gif, bmp.
  • For security purposes, both WordPress and Dolibarr should be served over HTTPS.
  • If on occasion, you want to create invoices with negative prices in Dolibarr, you will have to enable adding lines with negative prices on invoices. To do this go to Home > Setup > Other Setup and add a new parameter: FACTURE_ENABLE_NEGATIVE_LINES with the value of 1 and with the comment: ‘Allow lines with negative values on invoices.’, then click ‘ADD’.

15.8.2.8. Upgrading the ‘Sync WooCommerce with Dolibarr’ plugin

‘Sync WooCommerce with Dolibarr’ can be upgraded like any other WordPress plugin. When you log in to the WordPress admin area, you may notice a red circle announcing that there are new updates available. On the left panel click on Plugins and if you notice that ‘Sync WooCommerce with Dolibarr’ has an ‘update now’ link, click on it and the plugin will be updated to the last version.

15.8.2.9. Connect your WordPress website to the Memcached server to enable object caching

Remember that you installed the Memcached server and the php-memcache PHP extension earlier. In order to use Memcached for object caching for your WordPress site, you have to install the ‘Memcached Object Cache’ plugin, which is not a proper plugin, but a drop-in file that has to be copied to the /var/www/example.com/wp-content directory of the website.

Download the ‘Memcached Object Cache’ plugin from its official wordpress.org page to your local computer.

Extract the archive. You will find the object-cache.php file and a readme.txt file.

Use openssl to generate a random string that you will use as salt in the object-cache.php file:

openssl rand -base64 48

Copy the resulting string to the object-cache.php file from the extracted archive, like so:

if ( ! defined( ‘WP_CACHE_KEY_SALT’ ) ) {

define( ‘WP_CACHE_KEY_SALT’, ‘vkCi6QNmBT4drFMXpCIj57iEGte54CGO9yathz8fkdAsfP0k2gOU6lGdbLGHLqSh’ );

}

Then copy to clipboard the entire content of the modified object-cache.php file.

Create a new file called object-cache.php in the /var/www/example.com/wp-content directory of your WordPress website:

nano /var/www/example.com/wp-content/object-cache.php

Paste the content of the modified object-cache.php file from your local computer.

Change permissions for this file:

cd /var/www/example.com/wp-content
chown www-data:www-data object-cache.php
chmod 640 object-cache.php

Also, open the wp-config.php file for the example.com website (which has been copied outside the webroot, as /srv/scripts/example.php):

nano /srv/scripts/example.php

Add the following lines at the top of this file, right below <?php :

$memcached_servers = array( ‘default’ => array(

‘unix:///var/run/memcached/memcached.sock’)

);

Reload Nginx:

systemctl reload nginx

To check if Memcached is working as expected, first access a few pages of your website in a browser, then run:

/usr/share/memcached/scripts/memcached-tool /var/run/memcached/memcached.sock display

Sample output:

# Item_Size Max_age Pages Count Full? Evicted Evict_Time OOM

4 192B 264s 1 144 no 0 0 0

5 240B 265s 1 420 no 0 0 0

6 304B 265s 1 73 no 0 0 0

7 384B 264s 1 26 no 0 0 0

8 480B 264s 1 141 no 0 0 0

9 600B 264s 1 89 no 0 0 0

10 752B 110s 1 2 no 0 0 0

11 944B 264s 1 53 no 0 0 0

12 1.2K 264s 1 98 no 0 0 0

13 1.4K 264s 1 13 no 0 0 0

14 1.8K 264s 1 40 no 0 0 0

15 2.3K 264s 1 48 no 0 0 0

16 2.8K 264s 1 36 no 0 0 0

17 3.5K 264s 1 9 no 0 0 0

18 4.4K 264s 1 4 no 0 0 0

19 5.5K 8s 1 1 no 0 0 0

20 6.9K 8s 1 2 no 0 0 0

22 10.8K 132s 1 2 no 0 0 0

27 33.1K 108s 1 1 no 0 0 0

33 126.3K 107s 1 1 no 0 0 0

You can also see interesting statistics by running:

/usr/share/memcached/scripts/memcached-tool /var/run/memcached/memcached.sock stats

The result will look similar to the following:

#127.0.0.1:11211 Field Value

accepting_conns 1

auth_cmds 0

auth_errors 0

bytes 970047

bytes_read 3606266

bytes_written 11300197

cas_badval 0

cas_hits 0

cas_misses 0

cmd_flush 0

cmd_get 9710

cmd_set 1602

cmd_touch 0

conn_yields 0

connection_structures 14

crawler_items_checked 6855

crawler_reclaimed 1

curr_connections 13

curr_items 1467

decr_hits 0

decr_misses 0

delete_hits 13

delete_misses 50

direct_reclaims 0

evicted_active 0

evicted_unfetched 0

evictions 0

expired_unfetched 1

get_expired 1

get_flushed 0

get_hits 8140

get_misses 1570

hash_bytes 524288

hash_is_expanding 0

hash_power_level 16

incr_hits 2

incr_misses 0

libevent 2.1.8-stable

limit_maxbytes 268435456

listen_disabled_num 0

log_watcher_sent 0

log_watcher_skipped 0

log_worker_dropped 0

log_worker_written 0

lru_bumps_dropped 0

lru_crawler_running 0

lru_crawler_starts 1543

lru_maintainer_juggles 23400

lrutail_reflocked 41

malloc_fails 0

max_connections 2048

moves_to_cold 2196

moves_to_warm 1293

moves_within_lru 1139

pid 3187

pointer_size 64

reclaimed 0

rejected_connections 0

reserved_fds 20

rusage_system 0.716429

rusage_user 0.592205

slab_global_page_pool 0

slab_reassign_busy_deletes 0

slab_reassign_busy_items 0

slab_reassign_chunk_rescues 0

slab_reassign_evictions_nomem 0

slab_reassign_inline_reclaim 0

slab_reassign_rescues 0

slab_reassign_running 0

slabs_moved 0

threads 4

time 1597701450

time_in_listen_disabled_us 0

total_connections 17

total_items 1575

touch_hits 0

touch_misses 0

uptime 1049

version 1.5.6

If you want to see the actual data stored in memory, you can run:

/usr/share/memcached/scripts/memcached-tool /var/run/memcached/memcached.sock dump | less

To flush the Memcached cache run:

/usr/share/memcached/scripts/memcached-tool /var/run/memcached/memcached.sock dump > /dev/null

systemctl restart memcached

When you upgrade Debian, usually once every two years, it’s recommended to also upgrade the ‘Memcached Object Cache’ plugin, by downloading the newest version and following the steps from above again. We also mention this upgrade in the Maintenance steps chapter.

15.8.3. Secure WordPress

In the Nginx server blocks configuration file (/etc/nginx/sites-enabled/0-conf) we included the following block:

# Block XML-RPC requests

location = /xmlrpc.php {

deny all;

}

These lines block access to the xmlrpc.php script, thus disabling the XML-RPC feature of WordPress. This feature was added a long time ago so that users can log in to WordPress from other applications and publish their content from those applications. Today, when the Internet speed is much higher, WordPress users can write their content directly inside WordPress, after they log in to WordPress in the standard way, so the XML-RPC feature is very rearely used, if at all. Let alone the fact that today WordPress includes a powerful API that can be used to connect to WordPress websites from other applications and perform everything that would have been done with XML-RPC. The problem is that the XML-RPC feature can be used by attackers to gain access to your websites by automatically trying different combinations of usernames and passwords (brute-force attacks), or to perform DDoS attacks on other websites, by using the pingback feature of WordPress to send pingbacks to thousands of other websites in a short amount of time. To completely remove these security vulnerabilities you should disable the XML-RPC feature by including the configuration block from above in the server block of every WordPress website.

We already mentioned that changing the login URL from wp-login.php to a custom name, easy to remember for you but difficult to guess for an attacker, is one of the most efficient ways to prevent brute-force attacks. Earlier we described how to change the login URL by using the ‘WPS Hide Login’ plugin.

Please note that in the Nginx configuration presented above, in the server block for www.example.com, the new login page named get-web-net has been included in the section used to exclude certain pages from caching:

# Don’t cache URLs containing the following segments

if ($request_uri ~* “/wp-admin/|/get-web-net|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml”) {

set $skip_cache 1;

}

Don’t forget to change get-web-net with your own custom name.

15.8.3.1. Move wp-config.php outside the web root

In spite of what certain system administrators claim, one of the measures that you should take to increase the security of a WordPress website is to move the wp-config.php file to a directory outside the web root (/var/www). Here we’ll copy the wp-config.php file of the www.example.com website to /srv/scripts, under a different name, example.php, so as to easily identify it in the future:

cp /var/www/example.com/wp-config.php /srv/scripts/example.php

Then you can replace the content of the /var/www/example.com/wp-config.php file like this:

cd /var/www/example.com

cat /dev/null > wp-config.php

nano wp-config.php

Add the following content inside this file:

<?php include(‘/srv/scripts/example.php’); ?>

Then change the ownership and permissions for the /srv/scripts/example.php file:

cd /srv/scripts

chown www-data:root example.php

chmod 400 example.php

Repeat the steps from above for every WordPress website hosted on the server.

15.8.3.2. Remove the real usernames from the links of the XML sitemap

By default, WordPress automatically generates a XML sitemap containing a list of links to all the website pages, to help search engines to better understand the structure of the website. This sitemap can be accessed at the following URL:

https://www.example.com/wp-sitemap.xml

Unfortunately, by default, the list of links on the sitemap’s main page includes links like the following:

https://www.example.com/author/username/

where example.com is your domain, and username is the real username used to log in to WordPress, by the admin or any other user who wrote posts or pages. This happens even if the user has set a nickname on the user’s ‘Profile’ page, and has set that nickname to be displayed publicly, in the ‘Display name publicly as’ field. Therefore, any attacker can easily find a real username to log in with, and can use it in brute-force attacks. All that they have to guess is the password. If you think that this type of attack is improbable, read this topic opened by an admin who saw large numbers of failed log in attempts using a real username, as a consequence of the links present in the sitemap generated by WordPress, some of them being also present in the sitemap generated by the Yoast SEO plugin.

To prevent the sensitive usernames from appearing in the sitemap of a website (be it generated by WordPress or by a plugin), add the following lines at the bottom of the functions.php file of the child theme of that website:

// Remove the real usernames from the links of the sitemap

add_filter( ‘wp_sitemaps_add_provider’, function( $provider, $name ) {

if ( ‘users’ === $name ) {

return false;

}

return $provider;

}, 10, 2

);

15.8.3.4. Disable logging in by email address

The new versions of WordPress have logging in by email address enabled by default. In this way, a user who knows your personal email address from your website’s contact page or from an email that you sent him, can try to access the admin panel of your website by guessing the only thing that remains unknown, which is your login password. If you disable logging in by email address, the potential intruder will have to guess two things in order to get in: your username, which can and should be very different from ‘admin’, and difficult to guess, and the password. Two locks on the entrance door are better than one lock. This of course, implies that you set up a nickname for your admin user, and thus all your posts appear to be published by your nickname. Otherwise, the username that you log in with, will appear by default as the author of all your published posts and everybody will know your login username. This also implies that you removed the usernames from the links included in the XML sitemap, generated by WordPress or by a plugin, as explained in the previous subchapter.

Instead of installing an additional plugin to disable logging in by email address, you can just add the following lines at the bottom of the functions.php file located inside your child-theme’s folder (/var/www/example.com/wp-content/themes/parenttheme-child/functions.php):

// Replace ‘Username or Email Address’ label with ‘Username’

function change_login_username_label($translated_text, $text, $domain) {

if ($text === ‘Username or Email Address’) {

$translated_text = __( ‘Username’ );

}

return $translated_text;

}

// Add action to disable login username label, to login page head

function login_username_label() {

add_filter(‘gettext’, ‘change_login_username_label’, 20, 3);

}

add_action(‘login_head’, ‘login_username_label’);

// Add filter to change default username label on login page

function change_label_of_login_username($defaults) {

$defaults[‘label_username’] = __(‘Username’);

return $defaults;

}

add_filter(‘login_form_defaults’, ‘change_label_of_login_username’);

// Add filter to change WooCommerce login label

function change_wc_login_username_label($translation, $text, $domain) {

if (‘woocommerce’ === $domain) {

if (‘Username or email address’ === $text) {

$translation = ‘Username’;

}

}

return $translation;

}

add_filter(‘gettext’, ‘change_wc_login_username_label’, 10, 3);

// Disable logging in with email address

remove_filter(‘authenticate’, ‘wp_authenticate_email_password’, 20);

The configuration lines from above will disable logging in with email address to the WordPress admin section of your website but also to the WooCommerce ‘my account’ section, if you have WooCommerce installed. They will also replace the Username or Email Address label with Username, so that the users know that they can only log in with a username and password.

15.8.3.5. Configure Fail2ban to protect WordPress websites against brute-force attacks

The main measure that one has to take to protect the applications running on a server from brute-force attacks is to use Fail2ban to monitor access logs and ban all the IPs with a certain number of failed log in attempts. To configure Fail2ban to protect WordPress login pages, first navigate to the /etc/fail2ban/filter.d directory:

cd /etc/fail2ban/filter.d

Create a file called wordpress.conf:

nano wordpress.conf

Add the following lines inside this file:

[Definition]

failregex = ^<HOST> .* \”POST /wp-login.php HTTP/2.0\” 200 .*$

^<HOST> .* \”POST /xmlrpc.php HTTP/2.0\” 200 .*$

^<HOST> .* \”POST /my-account/ HTTP/2.0″ 200 .*$

^<HOST> .* \”POST /get-web-net/ HTTP/2.0\” 200 .*$

^<HOST> .* \”GET /get-web-net HTTP/2.0\” 401 195 .*$

ignoreregex =

Replace get-web-net with the customized name of your login page. If you changed the default name of the WooCommerce ‘my account’ login page from the default my-account to something else, replace my-account from above with the new name.

Please note that the first line containing the customized WordPress login page get-web-net and the POST command, protects the login page from repeated failed log in attempts, while the second line, the one with the GET command, protects the HTTP authentication dialog window that shows up before loading the actual login page, and can also be subject to brute-force attacks.

Open the /etc/fail2ban/jail.local file:

nano /etc/fail2ban/jail.local

Add the following section right below the [lighttpd-auth] section:

[wordpress]

enabled = true

filter = wordpress

logpath = /var/log/sites/example.com/access.log

/var/log/sites/secondsite.net/access.log

/var/log/sites/thirdsite.info/access.log

port = 80,443

maxretry = 4

bantime = 259200

where /var/log/sites/example.com/access.log and the other paths beneath it are the access log paths of all the WordPress websites that you host on the server.

Here you can also specify a findtime different from the default 960 seconds (16 minutes).

Save the file, then restart Fail2ban:

systemctl restart fail2ban

15.8.4. Disable wp-cron.php to improve website performance

WordPress uses the wp-cron.php file, located in the website’s root directory (/var/www/example.com/wp-cron.php), as a virtual cron job file, to automate tasks like checking for plugin and theme updates, sending email notifications, publishing scheduled posts, etc.

By default, WordPress calls wp-cron.php every time somebody visits a page (that isn’t cached), to check if there is any task to be run at that moment. For websites with high traffic, this results in many unnecessary calls and increased workload for the server. Therefore, it’s better to disable the default behavior regarding wp-cron.php.

To disable running wp-cron.php on every page load, add the following line to the wp-config.php file (which has been copied outside the webroot as /srv/scripts/example.php), right below define( ‘DB_COLLATE’, ” );:

define(‘DISABLE_WP_CRON’, true);

Then add a cron job to run wp-cron.php once a day:

crontab -e

Add the following lines at the bottom of the file:

# Run wp-cron.php once every day at 2:42 AM

42 2 * * * php /var/www/example.com/wp-cron.php; php /var/www/secondsite.net/wp-cron.php

You can add multiple wp-cron.php files to this line, one for each WordPress website, separated by semicolons, as shown above.

Of course, if you know that you need to run certain tasks much frequently, you can set up the cron job from above so that it runs once every 12 hours, once every 6 hours, etc.

15.8.5. Create a dev.example.com website to mirror your production www.example.com website, for testing purposes

The most responsible and professional way to install and update plugins and themes and to make changes to a live WordPress website is to first apply all the changes to a testing version of the website, and then, only if everything looks and works as expected, apply the changes to the live website.

The ‘testing’ or ‘development’ version of the website should be identical to the live website in all the major aspects: it should have the same theme, the same plugins installed and enabled and it should have the same main content. The ‘development’ website will be used only for testing purposes and will not be accessible to the public, or to the search engines. In principle, everything that you want to implement on the live website, you apply first on the development website and if everything looks and works as it should, you apply it to the live version. As a rule, on the development website you will need to have the same theme and child theme, the same plugins, the same customizations and the same main types of content. However, this doesn’t mean that you have to publish every future page or post twice: once on the development website and once on the live website. If you have a few of your pages and posts from the live website copied to the development website, and the new pages and posts don’t have a structure substantially different from that of the pages and posts already tested on the development site, you can publish them directly on the live version.

To clearly distinguish between a live website having the domain www.example.com and the development version of it, you should install the development version on a subdomain with a short and suggestive name: dev.example.com.

The best method to create the dev version of a live WordPress website is to clone the live website to the new domain. Although you can use a plugin to clone a WordPress website, the healthiest way to achieve this is manually, as described below. As we mentioned before, you should do manually everything that can be done manually (if it’s a one time job), to keep the number of installed plugins as low as possible. Even if a plugin can help you clone a website faster, it can create unnecessary files and it can store additional data in the website’s database, that can remain there even if you uninstall the plugin.

First add the necessary DNS records for dev.example.com, as you did for www.example.com.

To obtain a Let’s Encrypt SSL certificate for dev.example.com, first open the /etc/nginx/sites-enabled/0-conf file:

nano /etc/nginx/sites-enabled/0-conf

Add the following temporary server block at the bottom of the /etc/nginx/sites-enabled/0-conf file:

server {

listen 80;

listen [::]:80;

server_name dev.example.com;

location /.well-known/acme-challenge {

root /var/www;

}

}

Reload Nginx:

systemctl reload nginx

Get the Let’s Encrypt SSL certificate for dev.example.com by running:

certbot certonly –agree-tos –webroot -w /var/www/ -d dev.example.com

Replace example.com with your own domain.

Replace the temporary server block with the following blocks:

server {

listen 80;

listen [::]:80;

server_name dev.example.com;

return 301 https://dev.example.com$request_uri;

}

server {

listen 443 ssl http2;

listen [::]:443 ssl http2;

server_name dev.example.com;

root /var/www/dev.example.com;

index index.php;

ssl_certificate /etc/letsencrypt/live/dev.example.com/fullchain.pem;

ssl_certificate_key /etc/letsencrypt/live/dev.example.com/privkey.pem;

ssl_trusted_certificate /etc/letsencrypt/live/dev.example.com/chain.pem;

ssl_dhparam /etc/nginx/ssl/dhparam.pem;

ssl_session_timeout 4h;

ssl_session_cache shared:SSL:40m;

ssl_protocols TLSv1.2 TLSv1.3;

ssl_prefer_server_ciphers on;

ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

ssl_stapling on;

ssl_stapling_verify on;

add_header Strict-Transport-Security “max-age=63072000” always;

add_header X-Frame-Options SAMEORIGIN;

add_header X-Content-Type-Options nosniff;

location = /robots.txt {

allow all;

}

location /get-web-net {

auth_basic ‘Restricted’;

auth_basic_user_file /etc/nginx/htpass/dev.example.com;

try_files $uri $uri/ /index.php?$args;

}

location / {

allow 123.123.123.123;

deny all;

try_files $uri $uri/ /index.php?$args;

# prevent image hotlinking

location ~ .(jpg|jpeg|png|svg|gif)$ {

valid_referers none blocked ~.google. ~.bing. ~.yahoo. example.com *.example.com;

if ($invalid_referer) {

return 403;

}

}

}

location /.well-known/acme-challenge {

root /var/www;

}

location ~ \.php$ {

try_files $uri =404;

fastcgi_split_path_info ^(.+\.php)(/.+)$;

include fastcgi_params;

fastcgi_index index.php;

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

fastcgi_param HTTPS on;

fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;

fastcgi_cache phpcache;

fastcgi_cache_valid 200 301 302 60m;

add_header X-FastCGI-Cache $upstream_cache_status;

fastcgi_cache_bypass $skip_cache;

fastcgi_no_cache $skip_cache;

}

# Block XML-RPC requests

location = /xmlrpc.php {

deny all;

}

set $skip_cache 0;

# POST requests and urls with a query string should always go to PHP

if ($request_method = POST) {

set $skip_cache 1;

}

if ($query_string != “”) {

set $skip_cache 1;

}

# Don’t cache uris containing the following segments

if ($request_uri ~* “/wp-admin/|/get-web-net|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml”) {

set $skip_cache 1;

}

# Don’t use the cache for logged in users or recent commenters

if ($http_cookie ~* “comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in”) {

set $skip_cache 1;

}

# Browser caching

location ~* \.(css|js|gif|jpeg|jpg|png|ico)$ {

expires 72h;

add_header Pragma public;

add_header Cache-Control “public”;

}

# Block public access to .php files inside two sensitive directories

location ~* /wp-includes/.*.php$ {

deny all;

}

location ~* /wp-content/.*.php$ {

deny all;

}

access_log /var/log/sites/dev.example.com/access.log;

error_log /var/log/nginx/dev.example.com.error.log notice;

}

Replace example.com with your own domain, get-web-net with the custom name of your login page (which should be the same as on the live website), and 123.123.123.123 with the public IP of the computer that you use to access the dev.example.com website.

Create the access logs directory:

mkdir /var/log/sites/dev.example.com

If you restart Nginx at this point, you will get errors, because some necessary directories and files haven’t been created yet, so follow the steps from below.

Copy the directory of the live website, /var/www/example.com, to /var/www/dev.example.com:

cp -r /var/www/example.com /var/www/dev.example.com

The first thing that you have to change inside the /var/www/dev.example.com directory is the wp-config.php file, which is that of the live version. You need to adapt it, to make it work for the dev version.

Remember that you copied the /var/www/example.com/wp-config.php file to /srv/scripts/example.php, and you replaced the original content of the wp-config.php file with the following line:

<?php include(‘/srv/scripts/example.php’); ?>

You need to create a similar file, called /srv/scripts/dev.example.php, with the appropriate content, for the dev.example.com website. To do that, first copy the /srv/scripts/example.php file:

cp /srv/scripts/example.php /srv/scripts/dev.example.php

Then open the file for editing:

nano /srv/scripts/dev.example.php

Change the following line:

define( ‘DB_NAME’, ‘example.com’ );

with this line:

define( ‘DB_NAME’, ‘dev.example.com’ );

Then change the ownership and permissions for the new file:

chown www-data:root /srv/scripts/dev.example.php

chmod 400 /srv/scripts/dev.example.php

Then edit the /var/www/dev.example.com/wp-config.php file:

nano /var/www/dev.example.com/wp-config.php

Delete its content, then add the following line:

<?php include(‘/srv/scripts/dev.example.php’); ?>

Next, add Memcached object caching, so that the dev website be as identical as possible with the live version. Since you copied the content of the entire directory of the live version, you should have the /var/www/dev.example.com/wp-content/object-cache.php file in place. First create a random string to use as salt for the WP_CACHE_KEY_SALT parameter inside this file:

openssl rand -base64 48

Then, replace the key salt in /var/www/dev.example.com/wp-content/object-cache.php with the random string that you have just created. Open the file:

nano /var/www/dev.example.com/wp-content/object-cache.php

Make the WP_CACHE_KEY_SALT line look like this:

if ( ! defined( ‘WP_CACHE_KEY_SALT’ ) ) {

define( ‘WP_CACHE_KEY_SALT’, ‘9NVw2YKPLEnxsaEgjTORsHYP9dzXc/l0ADdipJq/USoWFZkXSq9PpVmESBxja3y4’ );

}

where 9NVw2YKPLEnxsaEgjTORsHYP9dzXc/l0ADdipJq/USoWFZkXSq9PpVmESBxja3y4 is the new salt.

Change ownership and permissions for /var/www/dev.example.com and its content:

chown -R www-data:www-data /var/www/dev.example.com

find /var/www/dev.example.com -type d -exec chmod 750 {} +

find /var/www/dev.example.com -type f -exec chmod 640 {} +

Create the /etc/nginx/htpass/dev.example.com password file containing the hashed password to be used for basic HTTP authentication by the user with access to the WordPress login page. If the user is sam, the command will be:

htpasswd -c /etc/nginx/htpass/dev.example.com sam

Enter the password twice. Change ownership and permissions for the new password file:

chown www-data:root /etc/nginx/htpass/dev.example.com

chmod 400 /etc/nginx/htpass/dev.example.com

Please note that the configuration block for preventing image hotlinking seems superfluous, since the dev website won’t be publicly accessible. Yet, it’s recommended to have it in place, so as to reproduce the whole Nginx configuration of the live website. As explained before, you want the dev version of the website to be as identical as possible to the live version, in all respects, so that when a new plugin or a newly added functionality works well on the dev version, you can be sure that it will work on the live version as well.

Please also note the following two lines in the location / block:

allow 123.123.123.123;

deny all;

where 123.123.123.123 is the public IP of the computer that you use to access the dev.example.com website. These two lines are extremely important: they deny access to dev.example.com to everybody, except you. Without these lines in place, the dev version of your website would become publicly accessible, visible to the search engines, it could create the ‘duplicate content’ problem, etc.

Finally, reload Nginx:

systemctl reload nginx

Now, that you have the files directory of the dev website in place and you have properly configured Nginx to serve the dev website, the only thing that remains to be done is to copy the database of the live website and modify that copy, so that the new website can use it.

First log in to phpMyAdmin, click on the name of the database (example.com in our example) on the left panel to select it, then click on ‘Operations’ on the upper bar. Scroll down to the ‘Copy database to’ section, enter the name of the new database, dev.example.com, verify that the following options are selected: ‘Structure and data’, ‘CREATE DATABASE before copying’, ‘Add AUTO_INCREMENT value’, ‘Add constraints’, ‘Adjust privileges’, like in the following image:

Then click ‘Go’. If the database is quite large, after copying it, phpMyAdmin may display the ‘connection timeout’ error, but don’t worry, the copying process has been completed. In this case, log out, restart the MariaDB server (systemctl restart mariadb) and then log in again to phpMyAdmin. You will find the new database in the list of databases, on the left panel.

The ‘Adjust privileges’ option will make the user that has privileges on example.com, have the same privileges on dev.example.com, so that the same user and password mentioned in the configuration file of the live version of the website can be used in the configuration file of the dev version. Remember that when you copied the configuration file from the live version to the dev version, you changed only the name of the database and used the same user and password. This will work because as mentioned, the user has now privileges on both databases: example.com and dev.example.com.

At this point, the development website has a database that is entirely identical to that of the live website. This is problematic, because the URL of the live website, namely https://www.example.com, and the name of the directory of the live website, namely /var/www/example.com will be mentioned multiple times inside the database of the dev website. Thus, for the dev website to be able to use the dev.example.com database, you need to change the website’s URL to https://dev.example.com and the directory name to /var/www/dev.example.com in all the places where they occur. You can do this by exporting the dev.example.com database to an sql file using phpMyAdmin, then using a text editor like ‘Mousepad’, ‘Gedit’ or ‘nano’ to replace the strings on your local computer, then removing all the tables of the dev.example.com database to empty it, then importing the modified sql file to the dev.example.com database. However, for a very large database this method won’t be appropriate, since the sql file will be very large and quite difficult to deal with. The best way to replace the specified strings is by using phpMyAdmin itself in the following way:

Log in to phpMyAdmin, click on dev.example.com on the left panel to select it, then click on ‘Search’ on the upper bar.

As shown in the picture from above, in the ‘Search in database’ field enter www.example.com. In the ‘Find’ section select ‘the exact phrase as substring’. In the ‘Inside tables’ section make sure all the tables are selected, as shown above; if they are not all selected by default, just click ‘Select all’. Then click ‘Go’.

As you can see in the picture from above, in this case, which may differ slightly from your case, the www.example.com string has been found 3 times in the wp_options table, 7 times in the wp_posts table and 1 time in the wp_users table. To be able to write SQL queries to replace those occurrences, you need to know the name of the columns in those tables, where the string was found. To find out the column names, just click on the ‘Browse’ link next to each table name and you will see all the rows of that table, where the searched string appears. Thus, you can identify the column(s) where the searched string appears inside that table.

It’s recommended to open each ‘Browse’ link in a new tab, so that you can use it to easily run the SQL queries needed to replace the string. Therefore, on the list of search results, instead of just clicking ‘Browse’ to see the occurrences, right-click on the ‘Browse’ link and choose ‘Open Link in New Tab’.

You will find for example that www.example.com was found:

– 3 times in the wp_options table, in the option_value column;

– 7 times in the wp_posts table, in the guid column;

– 1 time in the wp_users table, in the user_url column;

Now you can run the necessary queries to replace www.example.com with dev.example.com in all the places where it is mentioned.

In a SSH session, you can log in as root to your MariaDB server and run all the necessary queries, but the easiest way to run the SQL queries is to use each of the phpMyAdmin pages that you already opened in separate browser tabs: click on the ‘Console’ button from the bottom of each page, to maximize the SQL console, then enter the following queries (replace example.com with your own domain; also replace the names of tables and columns with your own names) and run them by pressing Ctrl + Enter, not just Enter:

UPDATE `dev.example.com`.`wp_options` SET option_value = REPLACE(option_value,’www.example.com’,’dev.example.com’);

UPDATE `dev.example.com`.`wp_posts` SET guid = REPLACE(guid,’www.example.com’,’dev.example.com’);

UPDATE `dev.example.com`.`wp_users` SET user_url = REPLACE(user_url,’www.example.com’,’dev.example.com’);

Then click again on dev.example.com on the left panel, click again on ‘Search’ on the upper bar and search for /var/www/example.com , in the same way you searched for www.example.com. The search results will show, for example, that /var/www/example.com was found one time in the wp_options table in the option_value column. If this is the case, write the following query in the console (replace example.com with your domain):

UPDATE `dev.example.com`.`wp_options` SET option_value = REPLACE(option_value,’/var/www/example.com’,’/var/www/dev.example.com’);

then press Ctrl + Enter to execute it.

Please note that in the dev.example.com database, there are also strings like admin@example.com. This is in fact the admin email that you entered when you installed WordPress on the live website. In this case, you would want the same admin email address in the dev website, therefore you shouldn’t run SQL queries to replace all the occurrences of example.com with dev.example.com, because they would also replace admin@example.com with admin@dev.example.com, which is not what you want. You should make the replacements exactly as mentioned above: replace www.example.com with dev.example.com and /var/www/example.com with /var/www/dev.example.com . If in your case, the searched strings are also found in other tables than the ones mentioned above, you should run similar SQL queries to make the replacements in those tables too.

At this point, the dev.example.com database can be used by the dev website, so, you can log in by accessing:

https://dev.example.com/get-web-net

replace example.com with your domain and get-web-net with the custom name of your login page, which will be the same as on the live website.

The method described above to replace specific strings in the dev.example.com database using phpMyAdmin, can be actually used to replace any strings in any MariaDB database.

15.8.6. Website under construction

In general, when a website is under development, since it doesn’t look as intended and it doesn’t have the proper content, you want to prevent visitors from seeing it, and you want to prevent search engine bots from crawling and indexing the website in this unfinished form. To prevent visitors from seeing an unfinished website and the search engine bots from crawling and indexing the website, you have to take the following four steps:

1) Add the following header to the server block of the website that you don’t want to be crawled and indexed (for the time being) by any search engine:

add_header X-Robots-Tag “noindex, nofollow, nosnippet, noarchive”;

2) Create a robots.txt file in the root directory of the website (/var/www/example.com), with the following content:

User-agent: *

Disallow: /

3) Make sure that the server block of the current website, from the /etc/nginx/sites-enabled/0-conf file, contains the following directives:

location = /robots.txt {

allow all;

}

Keep in mind that unfortunately, many search engine bots won’t respect the X-Robots-Tag directive mentioned above or the content of the robots.txt file, and will still crawl and index your site and show it in the search results.

Also, if a visitor knows the URL of your website (s)he can still visit it by entering the URL directly into the browser’s address bar, without the need to click a link on a search results page. Therefore, if you want to be sure that no search engine bots can crawl the website and no human visitor can see the content of the website in any manner, follow the next step.

4) Create a simple HTML file called underconstruction.html in the /var/www/example.com directory, having a content similar to the following:

<!DOCTYPE html>

<html>

<head>

<title>Coming soon</title>

<style>

body { background-color: #ededed; }

#announcement { padding: 240px; text-align: center; font: Helvetica, sans-serif; color: #333333; }

p { font-size: 19px; }

</style>

</head>

<body>

<div id=”announcement”>

<h2>This site is under construction!</h2>

<p>You can still contact us at info@example.com</p>

</div>

</body>

</html>

If you want to avoid the risk of having the email address info@example.com automatically harvested by spammers and used to send you annoying messages, you can replace info@example.com with a png/jpg image created with a program like Inkscape, containing your email address. In this manner, the typical automated tools used to detect email addresses on web pages will fail to harvest your email address. You can also use this method to protect the email addresses that you display on the Contact pages of your websites.

Then change the following lines in the Nginx configuration block for example.com (in the /etc/nginx/sites-enabled/0-conf file):

change this block:

location / {

try_files $uri $uri/ /index.php?$args;

}

with this:

location = /underconstruction.html {}

error_page 403 =200 /underconstruction.html;

location / {

allow 123.123.123.123;

allow 124.124.124.124;

deny all;

try_files $uri $uri/ /index.php?$args;

}

where 123.123.123.123 and 124.124.124.124 are the IP addresses that will be allowed to access the website. Nginx will send the underconstruction.html page to all the clients having IPs different from the IPs listed as allowed.

Reload Nginx to apply the changes:

systemctl reload nginx

When the website is finished, if you want the website to be accessible to the public and you want the search engines to crawl and index it, follow these steps:

1) Remove the add_header X-Robots-Tag “noindex, nofollow, nosnippet, noarchive”; line from the server block of the website, or comment it out.

2) Change the content of the robots.txt file to make it look like this:

User-agent: *

Disallow:

Sitemap: http://www.example.com/sitemap.xml

The Sitemap parameter is optional. Yet, mentioning the URL of the XML sitemap that is automatically generated by WordPress, or the URL of a different sitemap generated by a plugin (like Yoast SEO), can improve SEO.

The location = /robots.txt section from the server block in the Nginx server blocks configuration file, should remain the same.

3) Change the following section:

location = /underconstruction.html {}

error_page 403 =200 /underconstruction.html;

location / {

allow 123.123.123.123;

allow 124.124.124.124;

deny all;

try_files $uri $uri/ /index.php?$args;

}

to make it look like this:

# location = /underconstruction.html {}

# error_page 403 =200 /underconstruction.html;

location / {

# allow 123.123.123.123;

# allow 124.124.124.124;

# deny all;

try_files $uri $uri/ /index.php?$args;

}

Reload Nginx:

systemctl reload nginx

15.8.7. Website under maintenance

You can use the same strategy as described above when you put the site under maintenance.

If you make substantial changes to a website and they require that you suspend temporarily all access to the website, you’ll have to first announce your visitors about the exact time and (approximate) duration of the maintenance event and then configure the web server to show an ‘under maintenance’ page to all the visitors during the maintenance activities. All you have to do is to follow step 4 from the previous chapter, changing the name of the file from underconstruction.html to undermaintenance.html (changing this name also in the Nginx server blocks configuration file), and changing its text content to something like this:

“Our website is currently undergoing maintenance and will be back shortly. If you have any questions, please contact us at info@example.com“.

15.8.8. Upgrading WordPress themes and plugins

Before upgrading any themes or plugins on the live version of your website, you should apply them to the dev instance first. After you apply all the recent upgrades to the dev instance and see that all the functionalities are intact, and the website looks as expected, you can apply those upgrades on the live website.

You can upgrade themes or plugins by clicking on ‘Dashboard’ > ‘Updates’, selecting the plugins and themes that have new versions and you want to upgrade, then clicking the ‘Update Plugins’ and ‘Update Themes’ buttons, respectively. This assumes that when you customized the active theme of your website, you didn’t modify the theme directly, but you used a child theme, as described in this guide. To upgrade plugins you can also click on the ‘Plugins’ menu entry on the left panel, to access the list of installed plugins. You will see an ‘update now’ link next to any plugin that has a new version available.

15.8.8.1. Upgrading WordPress

Before upgrading WordPress to a new version, it’s recommended to check if the new version has been tested and confirmed to function well within the suite by visiting this page.

When a major WordPress version is released, it’s recommended that you first apply the upgrade

to the dev instance of your website, and then, if everything looks good and the website hasn’t lost any of its functionalities, you apply the upgrade to the live website as well.

By default, all minor updates and security updates are applied automatically in the background. So, your WordPress websites will be automatically upgraded from, let’s say, version 5.5 to 5.5.1, 5.5.2, etc. Only when you will be notified of a new major release update, by a red circle displayed on the ‘Dashboard’ > ‘Updates’ menu entry, you will need to click on ‘Dashboard’ > ‘Updates’ > ‘Update Now’.

So, once you see on the admin panel a notification about a new major release, you first upgrade the dev instance, and then the live instance of your website by taking the following steps:

1) First make a backup of the website’s database. The easiest way to make a backup of your website’s database is to log in to phpMyAdmin, click on the name of the database in the left panel to select it, then click on ‘Export’ in the upper bar, then click ‘Go’, then ‘Save File’, then ‘OK’. In this way, you will save a file called example_com.sql on your local computer. The first thing to do afterwards is to rename that backup file, so as to include the date of the backup in its name. So rename it to something like example_com-2020-08-17.sql, where 2020-08-17 is the date of the backup.

2) Then make a backup copy of the whole/var/www/example.com directory, which is the directory where all the files of the www.example.com website reside. Run:

cd /var/bm_archives

tar czf example.com-2020-08-17.tar.gz /var/www/example.com

where example.com is the domain of your website and 2020-08-17 is the date of the backup, which is very important. A backup without a date associated with it, is almost entirely useless because when you will find it along other backups, days or weeks later, you won’t know if it’s the backup taken before a certain negative event, such as a malware injection, or after that event. So, you won’t know if the files inside are clean and recent enough to rebuild your website with them, or not.

3) The next step is to put the two newly created files (example_com-2020-08-17.sql and example.com-2020-08-17.tar.gz) in a directory, then store that directory in a safe location, where only you have access. Since the database backup is saved on your local computer and the files backup is saved on the remote server, you will have to use a FTP client like FileZilla to bring them in the same directory. Usually the backups exist in three copies: one on the server on which they were created (usually in /var/bm_archives), the second on the system admin’s laptop/desktop and the third copy on an external hard drive.

4) Then, after you log in to your dev website, go to ‘Dashboard’ > ‘Updates’ > click on ‘Update Now’. Then check if the dev website looks and functions as expected, both in the admin area and on the public pages.

5) If after upgrade, the dev website looks and works as it should, you can perform the upgrade on the live version as well.

You can send your questions and comments to: