+1 (276) 265-7197

Hardening Apache and TLS Without Breaking the Application

Most of the LAMP servers we are handed pass a casual look and fail a scan. The certificate is valid, the site loads on HTTPS, and underneath that Apache is still negotiating TLS 1.0, serving /server-status to the internet, and renewing certificates from a cron job that has been failing since the last IP change. None of that is exotic. It is a couple of hours of careful config work, and it is the cheapest security spend available on a running system.

Here is the pass we run, in the order we run it, with the parts that break applications called out.

Before you touch anything: get a baseline

You need to know what the server does today so you can tell what changed.

openssl s_client -connect example.com:443 -tls1_1 </dev/null
curl -sI https://example.com | sed -n '1,20p'
apachectl -S
apachectl -M | sort

Run an external scan too (SSL Labs, testssl.sh, or nmap --script ssl-enum-ciphers -p443). Save the output. Every change below gets compared against that file, and apachectl configtest runs before every reload.

Also check the Apache version you are actually on: httpd -v or apache2 -v. Distro packages backport security fixes, so 2.4.52 on Ubuntu 22.04 is not the same thing as upstream 2.4.52 — check your vendor's advisory list rather than a version-number table. If the OS itself is out of support, hardening Apache is the second job; the first is the OS, and we wrote about that in the CentOS 7 migration post.

Protocols and ciphers

TLS 1.0 and 1.1 have been deprecated since 2021 (RFC 8996) and every current browser refuses them. The only thing they still serve is old API clients and payment terminals — which is exactly why you check the logs before turning them off. Enable protocol logging for a week:

LogFormat "%h %{SSL_PROTOCOL}x %{SSL_CIPHER}x %{User-Agent}i" tlslog
CustomLog logs/tls_access.log tlslog

If nothing real appears on 1.0/1.1, set:

SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLCipherSuite 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
SSLHonorCipherOrder off
SSLSessionTickets off

That is Mozilla's intermediate list; do not hand-assemble a cipher string from a 2014 blog post. TLS 1.3 ciphers are negotiated separately and need no configuration. SSLHonorCipherOrder off is correct on modern stacks — clients pick sensibly, and it lets fast clients choose ChaCha20.

If you genuinely still have one payment terminal that needs TLS 1.1, do not weaken the whole server: give it its own vhost on a separate port or hostname with the loose settings, and keep the main site strict.

HSTS, staged so you can back out

Strict-Transport-Security is effectively one-way for the duration of its max-age: browsers that have seen the header will refuse plain HTTP to your domain, and you cannot recall it. So ramp it.

Start at five minutes, confirm nothing on the site loads over plain HTTP (mixed content, a legacy subdomain on an internal CA, an integration that posts to http://), then raise it:

Header always set Strict-Transport-Security "max-age=300"
# after a week of clean logs:
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"

Add includeSubDomains only once you know every subdomain has a valid certificate — this is where it breaks people: an old intranet.example.com on a self-signed cert becomes unreachable for everyone who visited the main site. preload is a further one-way step; skip it unless someone actually needs it.

Also make the redirect clean: a 301 from port 80 to the canonical HTTPS host, done in a dedicated vhost, not in .htaccess rewrite spaghetti that fires twice.

Headers that are safe, and the one that is not

These four are cheap and rarely break anything:

Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Cross-Origin-Opener-Policy "same-origin"

The one that is not cheap is Content-Security-Policy. On a legacy PHP application with inline <script> blocks, inline onclick handlers, and a payment widget from a third party, a strict CSP will break the checkout on the first deploy. Do it properly or not at all: publish Content-Security-Policy-Report-Only with a report endpoint, collect violations for a week or two, and only then enforce. Half the value of a real CSP shows up in report mode anyway, because it tells you exactly which third-party scripts your pages load.

Also turn off the version banner while you are here — it is not security, but it removes a free hint:

ServerTokens Prod
ServerSignature Off

What is exposed that should not be

This is where the actual findings usually are. Check each one by hand against your live host:

  • /server-status and /server-info — useful, and often reachable from the internet. Restrict them to localhost or a VPN range.
  • /.git/ — if the app was deployed by git clone, the whole repository history, including old credentials, is downloadable. curl -sI https://example.com/.git/HEAD answers this in one second.
  • .env, composer.lock, phpinfo.php, adminer.php, backup.sql, *.sql.gz in the document root. Search the docroot for them; a deny rule on dotfiles and .sql is a good backstop, but removing the files is better.
  • Directory listings: Options -Indexes at the top level.
  • /vendor/ and other paths that should never be web-reachable. The clean fix is a document root that points at public/ only, which sometimes means a small deploy change — worth doing.
  • display_errors = Off and expose_php = Off in production php.ini, with errors going to a log file. Stack traces in a browser hand an attacker your paths and query structure.

Renewals that fail loudly

Expired certificates cause more outages than weak ciphers do. Two things:

  1. Confirm the renewal timer actually runs and actually reloads Apache. systemctl list-timers | grep certbot and certbot renew --dry-run. A renewal that writes new files but never reloads Apache serves the old certificate until someone restarts something months later.

  2. Monitor expiry from outside the box, on the hostname a browser uses. A cron script that checks the remaining days and alerts under 21 is enough:

     echo | openssl s_client -servername example.com -connect example.com:443 2>/dev/null \
       | openssl x509 -noout -enddate
    

Check every hostname, not just the main one — the forgotten api. or staging. subdomain is the one that expires on a Saturday.

Order of operations on a live site

Do it as one change window per group, each with its own rollback: protocols and ciphers first (measurable, reversible, no application effect), then exposed paths, then headers, then HSTS last because it is the one you cannot take back quickly. Keep apachectl configtest in front of every reload, watch the error log for five minutes after each, and re-run the external scan at the end against the baseline you saved.

A full pass on a typical single-server LAMP host takes an afternoon. If the scan comes back clean and the only finding is a cipher list that is already fine, we will tell you that and stop — there is no point billing hours against a server that is already in decent shape. If it comes back with /.git/ open and TLS 1.0 enabled, that afternoon was the best-value work on your roadmap this quarter.