Grav Production Deployment — Nginx, SSL, Monitoring and Scaling
In this tutorial, you'll learn Grav production deployment — configuring Nginx and Apache for production, setting up SSL/TLS certificates, implementing monitoring and alerting, scaling Grav horizontally, and following a go-live checklist.
What You'll Learn
- Production server configuration (Nginx, Apache)
- SSL/TLS certificate setup with Let's Encrypt
- Performance monitoring and alerting
- Horizontal scaling strategies for Grav
- Load balancing and high availability
- Go-live checklist and pre-launch verification
Why It Matters
In WordPress, production deployment involves database migrations, plugin compatibility checks, and often complex server setups. In Grav, deployment is simpler because there is no database. But production readiness still requires proper server configuration, SSL, security headers, monitoring, and scaling plans. A well-deployed Grav site handles traffic spikes gracefully, recovers from failures automatically, and gives you visibility into performance and errors.
Real-World Use
A SaaS company's documentation site runs Grav on a 3-server cluster behind a load balancer. Each server runs Nginx with FastCGI cache. Redis handles session and page caching across all servers. Monitoring alerts the team if response time exceeds 500ms or error rate exceeds 1%. SSL is managed with auto-renewing Let's Encrypt certificates. The setup handles 50,000 daily visitors with 99.9% uptime.
Learning Path
flowchart LR
A["CLI Tools"] --> B["Production Deployment
← You are here"]:::current
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
Nginx Configuration
/etc/nginx/sites-available/grav:
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name example.com www.example.com;
root /var/www/grav-site;
# SSL configuration
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Max upload size
client_max_body_size 50M;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
gzip_comp_level 6;
gzip_vary on;
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|webp|svg|css|js|woff|woff2|ttf)$ {
expires 365d;
add_header Cache-Control "public, immutable";
access_log off;
}
# Deny access to sensitive files
location ~* \.(yaml|md|twig|log|git)$ {
deny all;
return 404;
}
location ~ /\. {
deny all;
return 404;
}
# Deny access to user data directories
location ~ ^/user/data/(cache|images|accounts) {
deny all;
return 404;
}
# PHP processing
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
# FastCGI cache
fastcgi_cache GRAV;
fastcgi_cache_valid 200 60m;
fastcgi_cache_use_stale error timeout updating;
fastcgi_cache_min_uses 1;
fastcgi_cache_lock on;
add_header X-FastCGI-Cache $upstream_cache_status;
}
# Main location
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Logs
access_log /var/log/nginx/grav-access.log;
error_log /var/log/nginx/grav-error.log;
}
Apache Configuration
/etc/apache2/sites-available/grav.conf:
<VirtualHost *:443>
ServerName example.com
DocumentRoot /var/www/grav-site
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem
<Directory /var/www/grav-site>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
# Security headers
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "DENY"
Header always set X-XSS-Protection "1; mode=block"
# Deny access to sensitive files
<FilesMatch "\.(yaml|md|twig|log|git)$">
Require all denied
</FilesMatch>
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]
ErrorLog ${APACHE_LOG_DIR}/grav-error.log
CustomLog ${APACHE_LOG_DIR}/grav-access.log combined
</VirtualHost>
SSL/TLS with Let's Encrypt
# Install Certbot
sudo apt install certbot python3-certbot-nginx
# Obtain certificate
sudo certbot --nginx -d example.com -d www.example.com
# Auto-renewal (Certbot adds a systemd timer)
sudo certbot renew --dry-run
Monitoring Setup
Server Monitoring with Prometheus Node Exporter
# Install Node Exporter
wget https://github.com/prometheus/node_exporter/releases/download/v1.6.0/node_exporter-1.6.0.linux-amd64.tar.gz
tar xvf node_exporter-*.tar.gz
sudo mv node_exporter-*/node_exporter /usr/local/bin/
# Run as service
cat > /etc/systemd/system/node_exporter.service << EOF
[Unit]
Description=Node Exporter
[Service]
User=nobody
ExecStart=/usr/local/bin/node_exporter
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable node_exporter
sudo systemctl start node_exporter
Uptime Monitoring
Configure external uptime monitoring services:
# uptime-monitoring.yaml - Configuration for UptimeRobot/Pingdom
monitors:
- name: Grav Site
url: https://example.com
type: http
interval: 5
timeout: 30
alerts:
- email: admin@example.com
- sms: +1234567890
- name: Admin Panel
url: https://example.com/admin
type: http
interval: 15
timeout: 30
- name: SSL Certificate
url: https://example.com
type: ssl
interval: 1440 # Daily
alert_days_before: 14
Error Monitoring
// user/plugins/error-monitor/error-monitor.php
public function onShutdown()
{
$error = error_get_last();
if ($error && in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
$this->reportError($error);
}
}
private function reportError($error)
{
$data = [
'message' => $error['message'],
'file' => $error['file'],
'line' => $error['line'],
'url' => $_SERVER['REQUEST_URI'] ?? 'unknown',
'method' => $_SERVER['REQUEST_METHOD'] ?? 'unknown',
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'timestamp' => date('c'),
];
// Send to error tracking service
$ch = curl_init('https://api.example.com/errors');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 3);
curl_exec($ch);
curl_close($ch);
}
Horizontal Scaling
Shared Filesystem (NFS)
For multi-server setups, share the user/ directory via NFS:
# Server 1 (NFS server)
sudo apt install nfs-kernel-server
echo "/var/www/grav-site/user *(rw,sync,no_subtree_check)" >> /etc/exports
sudo exportfs -a
# Server 2, 3 (NFS clients)
sudo apt install nfs-common
sudo mount -t nfs server1:/var/www/grav-site/user /var/www/grav-site/user
Redis for Shared Caching
# system.yaml on all servers
cache:
driver: redis
redis:
server: redis.example.com
port: 6379
Load Balancer (HAProxy)
/etc/haproxy/haproxy.cfg:
frontend grav-http
bind *:80
redirect scheme https code 301 if !{ ssl_fc }
use_backend grav-servers
backend grav-servers
balance roundrobin
option httpchk GET /
server grav1 10.0.0.1:443 check
server grav2 10.0.0.2:443 check
server grav3 10.0.0.3:443 check
Go-Live Checklist
Pre-Launch
- SSL certificate installed and auto-renewal configured
- Security headers verified (securityheaders.com)
- Redis cache configured and tested
- Asset pipeline enabled (CSS/JS merge + minify)
- Image optimization: WebP, responsive sizes, lazy loading
- File permissions set: 755 dirs, 644 files, 640 config
- Debug mode disabled, Twig cache enabled
- Admin panel route changed from default
- Git repository configured with .gitignore
- Automated backup script in place
Launch
- DNS records configured (A record, CNAME for www)
- CDN configured (Cloudflare)
- Monitoring alerts configured (uptime, error, SSL expiry)
- Cache warmed (run
bin/grav cache:warmup) - Search engines sitemap submitted
- Google Analytics or tracking configured
- robots.txt configured
- 301 redirects from old URLs mapped
- Contact/forms tested
- Load test completed
Post-Launch
- Monitor error logs for 24 hours
- Verify SSL certificate (no mixed content)
- Check Lighthouse score (target: 90+)
- Review analytics after 1 week
- Set up regular maintenance schedule
Learning Path
flowchart LR
A["CLI Tools"] --> B["Production Deployment
← You are here"]:::current
classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px
Common Mistakes
Not testing SSL configuration: A misconfigured SSL certificate causes browser warnings. Use SSL Labs' SSL Test to verify your configuration.
Forgetting to warm the cache: The first visitor after deployment gets a slow page because the cache is cold. Always run cache warming after deployment.
Not setting up monitoring: Without monitoring, you learn about downtime from users. Set up uptime monitoring (Pingdom, UptimeRobot) and error tracking before launch.
Incorrect file permissions: Files with 777 permissions are a security risk. Files with 644 are not writable by the web server. Find the right balance with 755/644/640.
No scaling plan: If your site gets Slashdotted, will it survive? Plan for traffic spikes with caching, CDN, and auto-scaling before you need them.
Practice Questions
What is the recommended Nginx cache Strategy for Grav? Answer: Use FastCGI cache for PHP responses (60-minute TTL) with cache lock to prevent thundering herd. Add long-lived cache headers (365 days) for static assets.
How do you handle shared caching across multiple Grav servers? Answer: Use a centralized Redis server that all Grav instances connect to. The Redis cache driver shares cached pages, sessions, and configuration across servers.
What monitoring should be in place before a site goes live? Answer: Uptime monitoring (5-minute checks), SSL certificate expiry alerts (14 days before), error rate monitoring, server resource monitoring (CPU, memory, disk), and response time alerts.
How do you automate SSL certificate renewal for a Grav site? Answer: Install Certbot which adds a systemd timer for automatic renewal. Renewal checks run twice daily and renew certificates that expire within 30 days.
Challenge: Deploy a Grav site to production following a complete checklist. Set up: Nginx with SSL, FastCGI cache, and security headers, Redis for shared caching across 2 servers, load balancer with HAProxy, Cloudflare CDN, Prometheus monitoring with Grafana dashboards, Let's Encrypt auto-renewal, automated backup script running nightly, CI/CD pipeline with GitHub Actions, and load testing with 1000 concurrent users. Verify all components work together and document the architecture.
FAQ
Mini Project
Goal: Deploy a Grav site to production with complete infrastructure.
- Set up Nginx with SSL and security headers
- Configure Redis for cache and sessions
- Set up Cloudflare CDN
- Implement monitoring (uptime, errors, performance)
- Create automated backup script
- Set up CI/CD pipeline with GitHub Actions
- Run load test with 1000 concurrent users
- Verify Lighthouse score (90+)
- Complete the go-live checklist
- Create a runbook for the operations team
What's Next
Congratulations on completing the Grav CMS tutorial series. You have learned everything from Grav basics and installation to theme development, plugin creation, caching, security, and production deployment. To continue your learning, explore Twig for advanced template techniques, Symfony for deeper understanding of Grav's underlying framework, and CSS for advanced theme styling. Visit getgrav.org for official documentation and community support.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro