Apache VirtualHost Configuration on Ubuntu and Debian — A Complete Step-by-Step Guide
Setting up an Apache VirtualHost the right way is one of those tasks that feels simple until you hit your third 403 error, your certificate refuses to renew, or PHP-FPM silently falls back to mod_php and you can't tell why. This guide walks you through a production-ready Apache VirtualHost configuration for Ubuntu — with SSL via Let's Encrypt, PHP-FPM, separate access logs, and a small set of security hardening directives that should be in every config but rarely are. By the end, you'll have a .conf file you can drop into /etc/apache2/sites-available/, enable with a2ensite, and be confident it won't bite you in three months.
The target audience is the mid-level admin who knows their way around the terminal but doesn't want to memorize every Apache directive. If that's you, the rest of this article is the missing manual.
The VirtualHost generator — what it does and who it's for
The Apache VirtualHost configuration generator produces a complete .conf file from a short web form: domain, alias, SSL on/off, PHP version (5.6 through 8.5, including apache-mod for the rare cases you still need it), separate logs, security hardening, and browser cache for static files. The output drops straight into /etc/apache2/sites-available/ with a filename matching your ServerName, ready for a2ensite.
It exists because writing the same boilerplate by hand for the tenth time is a waste of an evening, and copying it from an old config tends to drag along stale directives like Order allow,deny (deprecated since Apache 2.4) or mod_php handlers that don't work with HTTP/2. The generator emits modern syntax — Require all granted, PHP-FPM via proxy_fcgi, Protocols h2 http/1.1 — and skips what your distro already provides in /etc/apache2/conf-enabled/.
It's aimed at single-server setups: VPS hosts, homelab boxes, small business web servers. If you're running a managed cluster with Ansible or Puppet, you probably have your own templates already. If you're rebuilding the same vhost three times a year for different projects, this saves time and prevents the typos that take an hour to debug.
Hands-on — building a VirtualHost step by step
A working Apache VirtualHost on Ubuntu has six moving parts: the listening port and ServerName, the document root, the directory permissions, the PHP handler, the logs, and (if SSL is on) the certificate paths plus an HTTP-to-HTTPS redirect. Let's go through them in the order they appear in the file.
Listening ports and ServerName
Every VirtualHost starts with the port it listens on and the hostname it answers to:
<VirtualHost *:80>
ServerName www.example.com
ServerAlias example.com
...
</VirtualHost>
*:80 means "any IP on port 80." On a single-IP server that's what you want. ServerName is the canonical hostname; ServerAlias is everything else that should resolve to the same site. If you want both example.com and www.example.com to land on the same vhost, name one as ServerName and the other as ServerAlias. The convention on most production sites is to canonicalize to www (or to bare domain — pick one and redirect the other), which is why the generator produces a 301 redirect from example.com to www.example.com.
For SSL setups, the port-80 vhost does nothing except redirect to HTTPS:
<VirtualHost *:80>
ServerName www.example.com
ServerAlias example.com
Redirect permanent / https://www.example.com/
</VirtualHost>
The actual content lives in the *:443 block.
Document root and directory permissions
DocumentRoot is where the files live. The convention on Debian-based systems is /var/www/{domain}/public_html:
DocumentRoot /var/www/www.example.com/public_html
<Directory "/var/www/www.example.com/public_html/">
Options FollowSymLinks
AllowOverride All
Require all granted
</Directory>
Three things to know about the <Directory> block:
Options FollowSymLinksallows symbolic links inside the document root. Don't addIndexesunless you specifically want a directory listing when there's noindex.html— most sites should not.AllowOverride Alllets you use.htaccessfiles inside the document root. Frameworks like Laravel and CMSs like WordPress depend on this for URL rewriting. If you don't use.htaccess, switch toAllowOverride Nonefor a small performance gain.Require all grantedis the modern access control directive (Apache 2.4+). If you're copying old configs that sayOrder allow,denyandAllow from all, replace them — that syntax was deprecated more than a decade ago and may stop working entirely in future versions.
PHP-FPM handler
Modern PHP on Apache means PHP-FPM, not mod_php. Two reasons: per-site PHP pools (different users, different memory limits, different versions per vhost) and HTTP/2 support — Apache's HTTP/2 module needs the Event MPM, which is incompatible with mod_php.
The handler block tells Apache to forward .php files over a Unix socket to the PHP-FPM master process:
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php/php8.4-fpm.sock|fcgi://localhost"
</FilesMatch>
Adjust the version (php8.3-fpm.sock, php7.4-fpm.sock, etc.) to match what's running. Verify with:
ls -la /run/php/
systemctl status php8.4-fpm
For this to work, you need two Apache modules enabled — proxy and proxy_fcgi:
sudo a2enmod proxy proxy_fcgi
sudo systemctl restart apache2
If you're still on mod_php for legacy reasons, omit the <FilesMatch> block entirely — mod_php handles PHP files natively. The generator's apache-mod option does exactly that.
Logs — combined or per-vhost
By default Apache writes to a shared error.log and access.log:
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
That's fine for one or two sites, painful for ten. Per-vhost logs make troubleshooting and log rotation much easier:
ErrorLog ${APACHE_LOG_DIR}/www.example.com-error.log
CustomLog ${APACHE_LOG_DIR}/www.example.com-access.log combined
${APACHE_LOG_DIR} is /var/log/apache2 on Ubuntu — defined in /etc/apache2/envvars. The combined format includes the referrer and User-Agent on top of the basic CLF fields. If you ship logs to Loki or Elasticsearch later, consider switching to a JSON LogFormat — the Apache mod_log_config docs cover the syntax.
SSL with Let's Encrypt
For HTTPS, the configuration adds a second VirtualHost on port 443 with three SSL-specific lines:
SSLEngine On
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
The path /etc/letsencrypt/live/example.com/ is where certbot stores certificates after running:
certbot certonly --agree-tos --email root@example.com --webroot \
-w /var/lib/letsencrypt/ \
-d example.com -d www.example.com
This issues a single certificate covering both domain names via SAN (Subject Alternative Name). Modern browsers ignore the certificate's Common Name and validate against SAN, so the same certificate file serves both the bare domain and the www variant. The directory name in /etc/letsencrypt/live/ is the first -d argument — keep that consistent with the path in your SSLCertificateFile directive or your renewal will silently use the wrong certificate.
Inside the :443 vhost, you'll also see a small redirect to canonicalize traffic:
<If "%{HTTP_HOST} == 'example.com'">
Redirect permanent / https://www.example.com/
</If>
This forces visitors who hit the bare domain over HTTPS to the www version, so search engines see one canonical URL and not two.
Security hardening
The generator includes an optional security block that should be on every public-facing vhost:
# Hide Apache version
ServerSignature Off
ServerTokens Prod
# Block access to .git, .env, .htaccess etc. (allow .well-known for ACME)
<DirectoryMatch "^/.*/\.git/">
Require all denied
</DirectoryMatch>
<FilesMatch "^\.(?!well-known)">
Require all denied
</FilesMatch>
ServerSignature Off and ServerTokens Prod together strip the Apache version and OS from response headers and error pages. Attackers fingerprint server versions to choose exploits — telling them you run "Apache/2.4.41 (Ubuntu)" is free intelligence.
The two <DirectoryMatch> and <FilesMatch> blocks block the most common accidents: leaving .git/ in the document root after deploying via git clone, and committing .env files with database credentials. The negative lookahead ^\.(?!well-known) blocks all dotfiles except .well-known/, which Let's Encrypt's ACME challenge needs to renew certificates. Skip the lookahead and your next renewal will fail with a 403.
Browser cache for static files
The generator also offers a Cache common filetypes: yes/no option, which adds two blocks:
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css application/javascript application/json
</IfModule>
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 month"
ExpiresByType image/png "access plus 1 month"
ExpiresByType image/webp "access plus 1 month"
ExpiresByType text/css "access plus 1 week"
ExpiresByType application/javascript "access plus 1 week"
</IfModule>
mod_deflate enables on-the-fly gzip compression for text-based responses — typically 60–80% size reduction on HTML, CSS, and JSON. mod_expires sets Cache-Control and Expires headers so browsers don't refetch unchanged images and CSS on every page load. Both modules ship with Apache and need to be enabled once globally:
sudo a2enmod deflate expires
sudo systemctl restart apache2
This is appropriate for traditional websites and CMSes. For an API or an admin panel that serves dynamic JSON on every request, leave it off — you don't want clients caching responses they should be re-fetching.
Enabling the site
Once the file is saved as /etc/apache2/sites-available/www.example.com.conf:
sudo a2ensite www.example.com.conf
sudo apache2ctl configtest && sudo apache2ctl graceful
configtest parses the configuration and reports Syntax OK (on stderr, not stdout — don't pipe it to grep without 2>&1) or a precise error with line number. graceful reloads workers without dropping in-flight requests, which is what you want on a live server. service apache2 status confirms Apache came back up.
You can find the full Apache 2.4 directive reference on httpd.apache.org — bookmark it, you'll come back.
Common mistakes and pitfalls
These are the errors that eat the most time when something goes wrong.
AH00558: Could not reliably determine the server's fully qualified domain name
This warning appears on every restart and looks alarming. It's not — Apache continues to serve traffic. The cause is a missing global ServerName in the main apache2.conf. Add a single line:
ServerName 127.0.0.1
to /etc/apache2/apache2.conf and the warning disappears. The DigitalOcean troubleshooting series covers this in depth.
PHP-FPM falls back to mod_php silently
You configured PHP-FPM but phpinfo() shows Server API: Apache 2.0 Handler. The cause is almost always mod_php still being enabled. Disable it explicitly:
sudo a2dismod php8.4
sudo a2enmod proxy proxy_fcgi
sudo a2enmod mpm_event
sudo a2dismod mpm_prefork
sudo systemctl restart apache2
mpm_prefork and mod_php go together — both must go for PHP-FPM to fully take over.
Certbot renewal fails with Failed to connect to host for DVSNI challenge
The renewal process needs to write a temporary file to /var/lib/letsencrypt/.well-known/acme-challenge/ and have Apache serve it over HTTP. If your security hardening block blocks all dotfiles without exempting .well-known, the challenge fails. The fix: use the negative-lookahead pattern shown above:
<FilesMatch "^\.(?!well-known)">
Require all denied
</FilesMatch>
Permission denied on DocumentRoot
Apache logs client denied by server configuration: /var/www/example.com/public_html. Two common causes: the <Directory> block is missing or has Require all denied, or the filesystem permissions don't let www-data read the directory. Check with:
sudo -u www-data ls /var/www/example.com/public_html/
If that fails, fix ownership:
sudo chown -R www-data:www-data /var/www/example.com/
sudo chmod -R 755 /var/www/example.com/
a2ensite succeeds but the site doesn't respond
Apache loads files from /etc/apache2/sites-enabled/ alphabetically, and the first matching VirtualHost wins for unmatched hostnames. If your filename starts with a digit lower than 000-default.conf, it can accidentally become the catch-all. Stick to the {servername}.conf convention — it sorts after 000-default.conf and behaves predictably.
SSL works on www but bare domain throws a certificate warning
Almost always a missing -d flag in your certbot command. The certificate must include both names in its SAN. Verify with:
openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -text | grep -A1 "Subject Alternative Name"
If only one domain shows up, regenerate with both:
certbot certonly --agree-tos --email root@example.com --webroot \
-w /var/lib/letsencrypt/ \
-d example.com -d www.example.com
Browser shows old content after deployment
mod_expires is doing its job — too well. If you set CSS to "access plus 1 week" and the user visited the site on Monday, they won't see Tuesday's CSS update until next Monday. Use cache-busting filenames (style.abc123.css) or query strings (style.css?v=2026.05.01) for assets that change. Static assets that never change (like /favicon.ico) can keep long cache windows.
Editing /etc/apache2/sites-enabled/ instead of sites-available/
Files in sites-enabled/ are symlinks. Editing them edits the originals, which is fine — but creating new files there breaks the a2ensite/a2dissite workflow. Always create configs in sites-available/ and symlink with a2ensite. Saves debugging "why won't this disable" later.
FAQ
Do I need a separate .conf file for every domain?
Yes, that's the standard pattern. One file per ServerName keeps the configuration organized, makes a2ensite/a2dissite straightforward, and isolates failures — a syntax error in one vhost doesn't take down the others. The generator names files {servername}.conf so they map 1:1 to the domain.
Can I put both port 80 and port 443 in the same file?
Absolutely, and it's the recommended pattern. The generator outputs both VirtualHosts in a single .conf file when SSL is enabled — the port-80 block redirects to HTTPS, the port-443 block holds the actual configuration. This keeps everything related to one domain in one place.
How do I run multiple PHP versions on the same server?
Install multiple PHP-FPM packages side by side (php7.4-fpm, php8.3-fpm, php8.4-fpm — most common via the ondrej/php PPA) and point each VirtualHost at the version it needs by changing the socket path in <FilesMatch>. Each PHP version runs its own master process and pool, so they don't interfere.
What's the difference between apache2ctl reload and apache2ctl graceful?
reload re-reads the configuration but kills active workers, which can drop in-flight requests. graceful lets active workers finish their current request before they're replaced. On production, always use graceful.
Should I use Options FollowSymLinks or Options SymLinksIfOwnerMatch?
SymLinksIfOwnerMatch is safer — it only follows symlinks when the link and target share an owner, preventing a writable directory from leaking into protected areas. The downside is a slight performance cost (Apache stats every link). For most single-tenant servers, FollowSymLinks is fine. On shared hosting, SymLinksIfOwnerMatch is the safer default.
Do I need to restart Apache after running certbot renew?
No. Certbot's renewal hooks (/etc/letsencrypt/renewal-hooks/deploy/) handle reloading Apache when a certificate is replaced. You only need to restart manually if you change the vhost configuration itself.
Can I use the same VirtualHost for multiple subdomains?
Yes — list each subdomain in ServerAlias:
ServerName example.com
ServerAlias www.example.com app.example.com api.example.com
For different document roots per subdomain, create separate VirtualHosts. For the same content under multiple names, one vhost with multiple aliases is fine.
Why does apache2ctl configtest say Syntax OK but my site still doesn't work?
configtest only checks syntax — it doesn't validate runtime behavior. The configuration may be syntactically valid but reference a missing file (typo in SSLCertificateFile), an unloaded module (SetHandler for proxy_fcgi without a2enmod proxy_fcgi), or an unreachable PHP-FPM socket. Check error.log after a graceful reload to catch these.
Next steps
Generate your first VirtualHost, drop it into /etc/apache2/sites-available/, run a2ensite and apache2ctl graceful, and you have a working site in under five minutes.
If you also need an SSL certificate, the Let's Encrypt certbot generator produces the matching certbot certonly command for the same domain. And if you're hosting your own DNS for the domain, the DNS zone file generator covers the SOA, NS, A, MX, and SPF/DKIM records you'll need.
I walk through this whole configuration end-to-end in a video on YouTube channel — including the gotchas around PHP-FPM, certbot renewal, and a live demo of what each security block actually blocks. Subscribe if you'd like the next tutorials in your feed.