I’ve been wanting to do this for a long time, now. I’ve been dabbling with a variety of ways to implement a template for using the Amazing Speed & Caching mechanisms of nginx for serving up static files & content that combines the superior Event MPM & PHP-FPM capabilities that Apache v2.4+ offers for crunching server-side processes (or something like FastCGI for Perl or FastCGI WSGI for Python).
There are a lot of different low-cost cloud infrastructure options that are becoming more mainstream, such as the low cost Vultr VPS & IONOS VPS S package for just $2 a month (It’s surprisingly awesome, too – The other larger IONOS VPS packages are the best price around the web right now, as well). If you prefer to play the “free trial for a year” games with the more complicated AWS & Azure service & stacks, you can also get some pretty cheap Cloud VMs that way, too (just brace yourself for the bill that’s coming next year when the trial is over & you’re married to the chosen platform).
When you combine the low-cost infrastructure options with services like Cloudflare DNS, & ZOHO suite, or some low-cost shared hosting solution combo product for managing data & email resources, you can really do a lot for very low cost.
A properly configured IONOS VPS S using nginx for serving (& caching) HTML, CSS, JavaScript, Images, Videos, etc with a load-balanced & reverse-proxy configuration to an Apache upstream to handle the PHP, Perl and/or Python combined with the power of Cloudflare DNS & its feature rich optimizations & caching… it’s not 2004 anymore! You can really serve up a TON of web requests with minimal resources & next to no cost at all for the static content itself. Prices start climbing higher when you get into things like network-intensive operations (eg: hosting or consuming huge amounts of API calls or maintaining a rapidly growing datastore of some kind) audio or video transcoding, sending or receiving large files or streaming. But most of these rather niche & specific operations can always be off-loaded to targeted resources or 3rd party services to cope with the heavy lifting. The majority of public-facing apps & websites are primarily for informational & transactional purposes. If you were to throw in a Redis cache or maybe a Varnish configuration in some cases, you could serve a mind blowing amount of content from the lowest cost “cloud resources”.
I’ve always got a thrill from tuning a system & squeezing every penny out of operational costs, so this guide is intended to be just another step in the direction of “cheap, fast AND good”.
Anyway. Enough talk. Let’s get to it.
NOTE: Eventually I’ll probably break everything down into stages and steps to follow. Apache 1st, nginx 2nd, whatever the database/datastore is 3rd, and possibly some kind of Docker or containerization setup for consistent deployment & code promotion environments with a CI/CD deployment strategy to wrap things up. For now, I’ll start by sharing the web server config file templates & eventually I’ll get around to breaking down each part that people can pick and chose how they want their environment(s).
Goals of this guide:
Have nginx serving up static content on port 80 and 443
Use Apache for all things PHP. Purely running PHP-FPM with Event MPM Module. (For now, we’ll leave out SSL with Apache since Nginx will be handling that. If we start adding multiple remote Apache backends, we will need to add SSL to those as well. Since Apache will be running on the same server instance as nginx in this scenario, we will assume all traffic to Apache (basically any requests for PHP) get sent to port 8080.
We will assume Debian / Ubuntu-style Apache Web Server is being used for this guide. There are quite a few nuances between Ubuntu & CentOS/RHEL/Fedora etc, but if you are choosing those flavors of Linux, I hope you understand these nuances and adjust your configuration accordingly. Since Debian-based Linux is the most popular, I’ll stick with that paradigm throughout the rest of this guide. I’ll also assume you’re working off of a “fresh install” with no programs running that will conflict with ports 22, 80, 443, 3306 or 8080.
Install apache2 with:
sudo apt-get update; sudo apt-get install apache2 -y;
next, let’s change the default port that Apache2 listens on
sudo nano /etc/apache2/ports.conf
Inside /etc/apache2/ports.conf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# If you just change the port or add more ports here, you will likely also # have to change the VirtualHost statement in # /etc/apache2/sites-enabled/000-default.conf #Listen 80 ### Comment this default line out here ### You can listen on port 8080 for ALL addresses with *:8080 ### However, we aren't going to use SSL with Apache so this is a security risk ### But... it is a good debugging option for test proper PHP config ### So add the line below & we will comment this line out later after testing Listen *:8080 ### Go ahead and add this line COMMENTED below since we will eventually be using it after testing #Listen 127.0.0.1:8080 ... |
Save the /etc/apache2/ports.conf file & close it.
Since the default apache Virtualhost is also listening on port 80, let’s change that too in case in tries to cause us grief later on (sometimes it does).
sudo nano /etc/apache2/sites-enabled/000-default.conf
Inside the “file” /etc/apache2/sites-enabled/000-default.conf
(technically it’s a symlink to /etc/apache2/sites-available/000-default.conf)
1 2 3 4 5 6 7 8 9 10 11 12 |
#<VirtualHost *:80> #comment this line out <VirtualHost *:8080> ### add this line, uncommented. ... # Don't make any more changes to this file # Just take notice of these lines down below ... #ServerName www.example.com ServerAdmin webmaster@localhost DocumentRoot /var/www/html ### The DocumentRoot sometimes varies between Linux Distros, ### Take note of its path. We'll be using this for a test in a little while. |
Save & exit the “file” /etc/apache2/sites-enabled/000-default.conf
By now, I’m sure the more seasoned & experiences Devs & Engineers are annoyed with my step-by-step for the n00bz. So this is where we would add in our own “custom” VirtualHost directive for Apache (aka vhost).
Since we’re already in the apache directories, let’s do that now:
Read this carefully:
sudo nano /etc/apache/sites-available/domain-name-goes-here.com.conf
Notice the difference between sites-enabled
& sites-available
. Very important for later.
Also, obviously be sure to substitute your domain name (or even an IP address could potentially work) where you see: domain-name-goes-here.com
You may have notice my super long path of:
/var/www/vhosts/user_accountname/domain-name-goes-here.com/actual-web-site-doc-root
You’ll need to change that to fit where ever your path is to your website DocumentRoot, below. I will suggest that if you plan on having more than one domain name hosted from your super nifty future bling bling tricked out nginx+Apache+Cloudflare site that we’re building now, that you at least separate your directory paths by one level instead of the 3 that I have shown as an example here. This will help keep your clients and their individual websites/apps separated and also give “one level up” for the actual website document root path to store certain content and provide some security. Sure, there’s a case against nesting too deeply due to “traversing” but CPUs are blistering fast nowadays. I wouldn’t worry about it. And that’s coming from an optimization enthusiast.
Having a flexible directory structure to keep all your own projects as well as your client projects organized will probably help you out later down the road. You can also always symlink your directory tree to where ever you like on your server setup, too.
Example file and directory structure in a crude & terrible, quick & dirty diagram that I’ll have to replace later:

Anyway, edit your virtualhost .conf file to fit your directory layout.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# Apache Web Server Configuration for domain-name-goes-here.com:8080 <VirtualHost *:8080> DocumentRoot /var/www/vhosts/user_accountname/domain-name-goes-here.com/actual-web-site-doc-root ServerName domain-name-goes-here.com ServerAlias domain-name-goes-here.com www.domain-name-goes-here.com CustomLog /var/log/apache2/domain-name-goes-here.com.access.log combined ErrorLog /var/log/apache2/domain-name-goes-here.com.error.log <Directory "/var/www/vhosts/user_accountname/domain-name-goes-here.com/actual-web-site-doc-root"> Options -Indexes +FollowSymLinks +MultiViews AllowOverride All Order allow,deny allow from all Require all granted </Directory> # This `FilesMatch` block isn't necessary when only php-fpm is enabled (eg: `sudo a2dismod php7.4`) and mod-php is disabled (eg: `sudo a2dismod php7.4`) - but keeping here for safety & reference <FilesMatch \.php$> ProxyErrorOverride On SetHandler "proxy:unix:/var/run/php/php7.4-fpm.sock|fcgi://localhost" </FilesMatch> </VirtualHost> # Excellent write-up on optimizing your Apache configuration (update to PHP7.4+ to suit your upgrate needs) => https://www.digitalocean.com/community/tutorials/how-to-configure-apache-http-with-mpm-event-and-php-fpm-on-ubuntu-18-04 |
Save the file & exit.
If you already have web content in your directory, cool. I’m assuming you do at least have an index.php or index.html/js/etc in your document root if you’re G enough to be looking into how to do a reverse proxy config in the first place. 😉
Since we’re assuming a Debian-style default Apache setup (CentOS & RHEL don’t do this “sites-enabled” thing by default) we need to add a symlink to “enable it”. We can do that in two ways:
Old school Linux SysAdmin style:
1 |
ln -s /etc/nginx/sites-available/domain-name-goes-here.com.conf /etc/nginx/sites-enabled/domain-name-goes-here.com.conf |
or, use the apachectl tools with:
sudo a2ensite domain-name-goes-here.com
There’s more to come with Apache, but for now, I’ll stop for the night and add the PHP7.4 install with all its modules and conf stuff, then onto nginx and the testing of the reverse proxy setup.
I will go ahead and dump off the template for the nginx vhost.conf here to get that out of the way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# domain-name-goes-here.com domain configuration - SSL(:443) with reverse proxy to Apache(:8080) in upstream template upstream upstream-domain-name-goes-here.com{ server domain-name-goes-here.com:8080; #See: http://nginx.org/en/docs/http/ngx_http_upstream_module.html#upstream # server unix:/var/www/vhosts/user_accountname/domain-name-goes-here.com/actual-web-site-doc-root; } # Redirect all traffic to port 80 (non-SSL) to port 443 (SSL) #redirect to www, always server { listen 80; server_name domain-name-goes-here.com; return 301 https://www.$host$request_uri; } #redirect to www, always, even on https://(:443) server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name domain-name-goes-here.com ; ssl_certificate /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/domain-name-goes-here.com-tld-cert-cloudflare-bundled-ecdsa-origin_ca_ecc_root.pem; ssl_certificate_key /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/domain-name-goes-here.com-tld-key.pem; ssl_trusted_certificate /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/cloudflare-bundled-ecdsa-origin_ca_ecc_root.pem; # (eg /etc/nginx/ssl/fullchain.pem; || /path/to/root_CA_cert_plus_intermediates;) resolver 127.0.0.1; # Find Cloudflare Resolver IP -> https://shadowcrypt.net/tools/cloudflare return 301 https://www.$host$request_uri; } #primary server directive server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name www.domain-name-goes-here.com ; #Log files configs & locations access_log /var/log/nginx/domain-name-goes-here.com.access.log; error_log /var/log/nginx/domain-name-goes-here.com.error.log; #Site DocumentRoot, Index & settings root /var/www/vhosts/user_accountname/domain-name-goes-here.com/actual-web-site-doc-root; index index.php index.html index.htm; client_max_body_size 20M; ##### BEGIN error handling location /error-pages/ { internal; } # error_page 404 = /error-pages/404-page-not-found.php; # error_page 403 = /error-pages/403-forbidden.php; # error_page 500 502 503 504 = /error-pages/500-internal-server-error.php; # error_page 500 502 503 504 /50x.html; location = /50x.html { root /var/www/html; } ##### END error handling ##### W3 Total Cache - configuration file path #uncomment to enable # include /path/to/wordpress/installation/nginx.conf; ### allows for tools that use directories or config files (eg: Let's Encrypt) that use this directory (in a required & legitimate way) location ~ ^/\.well-known\._others { allow all; } ### protect xmlrpc.php from attacks location /xmlrpc.php { deny all; } location / { try_files $uri @apache; } location ~ ^/\.user\.ini { deny all; } location ~* \.(svg|svgz)$ { types {} default_type image/svg+xml; } location = /favicon.ico { log_not_found off; access_log off; } location = /robots.txt { allow all; log_not_found off; access_log off; } location @apache { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; # proxy_pass http://upstream-$host$uri$is_args$args; # IMPORTANT: $is_args$args; Use HTTP for Apache(:8080) Reverse Proxy! ( https://wordpress.stackexchange.com/a/283101 ) proxy_pass http://upstream-domain-name-goes-here.com$uri$is_args$args; # IMPORTANT: $is_args$args; Use HTTP for Apache(:8080) Reverse Proxy! ( https://wordpress.stackexchange.com/a/283101 ) } location ~[^?]*/$ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; # proxy_pass http://upstream-$host$uri$is_args$args; # IMPORTANT: $is_args$args; Use HTTP for Apache(:8080) Reverse Proxy! ( https://wordpress.stackexchange.com/a/283101 ) proxy_pass http://upstream-domain-name-goes-here.com$uri$is_args$args; # IMPORTANT: $is_args$args; Use HTTP for Apache(:8080) Reverse Proxy! ( https://wordpress.stackexchange.com/a/283101 ) } location ~ \.php$ { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; # proxy_pass http://upstream-$host$uri$is_args$args; # IMPORTANT: $is_args$args; Use HTTP for Apache(:8080) Reverse Proxy! ( https://wordpress.stackexchange.com/a/283101 ) proxy_pass http://upstream-domain-name-goes-here.com$uri$is_args$args; # IMPORTANT: $is_args$args; Use HTTP for Apache(:8080) Reverse Proxy! ( https://wordpress.stackexchange.com/a/283101 ) } location ~/\. { deny all; access_log off; log_not_found off; } ##### BEGIN SSL Configuration for domain-name-goes-here.com (See /etc/nginx/nginx.conf SSL block for Shared Config settings) ssl_certificate /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/domain-name-goes-here.com-tld-cert-cloudflare-bundled-ecdsa-origin_ca_ecc_root.pem; # Cloudflare Universal SSL Cert ssl_certificate_key /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/domain-name-goes-here.com-tld-key.pem; # Cloudflare Universal SSL Key ### SSL Sessions Settings ssl_session_timeout 1d; # IMPORTANT: using $host variable in 'ssl_session_cache' for the domain / subdomain that is in use (this is compatible with dynamic multi-domain & single domain configurations) ssl_session_cache shared:$host-session-cache-SSL:10m; # 10m = about 40000 sessions, shared(caching type):domain.name(cache name):XXsmhdMY(cache timer) # ssl_session_tickets off; ### DH params options ssl_dhparam /etc/ssl/dhparam.pem; # (`openssl dhparam -out /etc/ssl/dhparam.pem 4096`) ### SSL Protocols & Ciphers (TLSv1.3 most secure; SSLv3, TLSv1 and TLSv1.1 are depricated) # Most Secure with TLSv1.3 ONLY (lease backwards compatible) # Use with Cloudflare Proxy for better security ssl_protocols TLSv1.3; ssl_ecdh_curve X25519:P-256:P-384:P-224:P-521; #untested ssl_prefer_server_ciphers on; ### SSL Security Settings ### HSTS ("max-age=63072000" option is in seconds) # SEVERE Consequences for disabled SSL per domain before this cache expires! see here: https://support.cloudflare.com/hc/en-us/articles/204183088-Understanding-HSTS-HTTP-Strict-Transport-Security # add_header Strict-Transport-Security "max-age=31536000 includeSubDomains" always; # includeSubDomains "is in options" ### OCSP stapling ssl_stapling on; ssl_stapling_verify on; ssl_stapling_file /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/domain-name-goes-here.com_cloudflare_ocsp.resp; # https://gist.github.com/fh/7444667 ssl_stapling_responder http://ocsp.cloudflare.com/origin_ecc_ca; # ssl_trusted_certificate /etc/nginx/ssl/cloudflare-bundled-ecdsa-origin_ca_ecc_root.pem; # (eg /etc/nginx/ssl/fullchain.pem; || /path/to/root_CA_cert_plus_intermediates;) ssl_trusted_certificate /var/www/vhosts/user_accountname/domain-name-goes-here.com/ssl/cloudflare-bundled-ecdsa-origin_ca_ecc_root.pem; # (eg /etc/nginx/ssl/fullchain.pem; || /path/to/root_CA_cert_plus_intermediates;) # resolver 104.27.147.179; # Find Cloudflare Resolver IP -> https://shadowcrypt.net/tools/cloudflare resolver 127.0.0.1; ##### END SSL Configuration for domain-name-goes-here.com } |
EDIT: I wasn’t quit finished with this article that I started a few weeks ago, but I wanted to get it out there, even unpolished, for a spotlight on what I’ve been up to lately for helping make my case for an awesome opportunity I’ve been made aware of this week. 😉
More polish and content to come.