Hosting MkDocs + Material on VPS
Testing deployment
Before preparing the production environment, the deployment process was tested on a temporary VM to verify:
- directory structure
- NGINX behavior
- SELinux context handling
- firewall rules
- MkDocs build output
- basic HTTP serving
On Host OS (Arch Linux)
Generate site/ directory containing the static output:
mkdocs build
Upload the build to the test VM:
scp -r site/ USER@IP_VM:/home/USER/site_deployment
The upload path can be any directory owned by the user. If it does not exist, create it manually.
Preparing the Directory Structure
Create directories:
sudo mkdir -p /var/www/mkdocs
sudo mkdir -p /var/www/mkdocs/releases
Move the build into the release directory:
sudo mv /home/user/site_deployment /var/www/mkdocs/releases/$(date +%Y%m%d_%H%M)
Create the current Symlink:
cd /var/www/mkdocs
sudo ln -s releases/$(ls releases | sort | tail -n1) current
Setting correct ownership:
sudo chown -R nginx:nginx /var/www/mkdocs
Basic NGINX Configuration
Edit:
sudo vim /etc/nginx/conf.d/mkdocs.conf
This configuration is intentionally minimal and not suitable for production.
Example config:
server {
listen 80;
server_name _;
root /var/www/mkdocs/current;
index index.html;
access_log /var/log/nginx/mkdocs_access.log;
error_log /var/log/nginx/mkdocs_error.log;
location / {
try_files $uri $uri/ =404;
}
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header X-XSS-Protection "1; mode=block";
}
Firewall (firewalld)
Check current rules:
sudo firewall-cmd --list-all
If port 80 is missing:
sudo firewall-cmd --add-port=80/tcp --permanent
sudo firewall-cmd --reload
SELinux Configuration
Test first before making such changes! If everything works, you can skip this step.
Allow NGINX to read the MkDocs directory:
sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/mkdocs(/.*)?"
sudo restorecon -Rv /var/www/mkdocs
Testing NGINX
sudo nginx -t
sudo systemctl restart nginx
If everything is correct, the site should be accessible at: http://VM_IP:80
Production Deployment
This guide describes the full deployment process for a static MkDocs site on a hardened VPS using NGINX, Let’s Encrypt, nftables, and Cloudflare.
Important Safety Notice
This deployment guide contains instructions that modify critical system components such as:
- firewall rules
- NGINX configuration
- SSL/TLS certificates
- DNS and Cloudflare settings
- file permissions and ownership
- system‑level services
These steps are intended only for users who understand the implications of each command. Incorrect configuration may result in:
- loss of access to the server
- broken HTTPS
- failed certificate renewals
- exposing the server to the public internet
- downtime or service unavailability
- security vulnerabilities
Do not apply these instructions on a production environment unless you fully understand what each step does!
HTTPS and Port 80 Policy
Port 80 must remain open permanently. Let’s Encrypt uses HTTP‑01 validation for automatic certificate renewal, and closing port 80 breaks this process.
For static sites, port 80 should:
- stay open in the firewall
- be handled by NGINX
- immediately redirect all traffic to HTTPS
Preparing the Deployment Structure
Create dirs:
sudo mkdir -p /var/www/mkdocs
sudo mkdir -p /var/www/mkdocs/releases
sudo mkdir -p /home/USER/site_deployment
Build locally:
mkdocs build
Upload to the server:
scp -P PORT -i ~/.ssh/KEY -r site/ USER@IP_VPS:/home/USER/site_deployment
Where:
- PORT — your custom SSH port
- KEY — your SSH private key
- USER — your VPS user
Move the uploaded build into the releases directory with a timestamp:
sudo mv /home/USER/site_deployment /var/www/mkdocs/releases/$(date +%Y%m%d_%H%M)
Update the current symlink and permissions:
cd /var/www/mkdocs
sudo ln -s releases/$(ls releases | sort | tail -n1) current
sudo chown -R nginx:nginx /var/www/mkdocs
System Update:
sudo dnf update
NGINX
Install and Enable NGINX:
sudo dnf install nginx
sudo systemctl enable nginx
Temporary NGINX Configuration (for initial HTTP testing):
sudo vim /etc/nginx/conf.d/mkdocs.conf
server {
listen 80;
server_name _;
root /var/www/mkdocs/current;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Firewall Configuration (nftables):
Edit:
sudo vim /etc/sysconfig/nftables.conf
Add rules:
tcp dport 80 accept
tcp dport 443 accept
Example full config:
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
ct state established,related accept
iif "lo" accept
tcp dport PORT accept
tcp dport 80 accept
tcp dport 443 accept
reject
}
}
DNS Configuration (Cloudflare)
| Type | Name | Value | Proxy | TTL |
|---|---|---|---|---|
| A | @ | VPS_IP | OFF | Auto |
| A | www | VPS_IP | OFF | Auto |
Proxy must be OFF during certificate issuance.
Certificate
Install Certbot:
sudo dnf install certbot python3-certbot-nginx
Issue the Certificate:
sudo certbot --nginx -d EXMAPLE.EXMAPLE -d www.EXMAPLE.EXMAPLE
Certbot may issue the certificate but fail to install it automatically. Use the hardened NGINX config below.
Final NGINX Configuration
sudo vim /etc/nginx/conf.d/mkdocs.conf
server_tokens off;
##############################
# 1. MAIN HTTPS SERVER BLOCK
##############################
server {
listen 443 ssl;
http2 on;
server_name EXMAPLE.EXMAPLE www.EXMAPLE.EXMAPLE;
root /var/www/mkdocs/current;
index index.html;
ssl_certificate /etc/letsencrypt/live/EXMAPLE.EXMAPLE/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/EXMAPLE.EXMAPLE/privkey.pem;
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location ~ /\.(?!well-known) {
deny all;
}
if ($request_method !~ ^(GET|HEAD)$ ) {
return 405;
}
client_max_body_size 1m;
location / {
try_files $uri $uri/ =404;
}
}
#############################################
# 2. HTTP → HTTPS REDIRECT
#############################################
server {
listen 80;
server_name EXMAPLE.EXMAPLE www.EXMAPLE.EXMAPLE
return 301 https://$host$request_uri;
}
########################################################
# 3. DEFAULT BLOCK — BLOCK DIRECT IP ACCESS ########################################################
server {
listen 80 default_server;
listen 443 default_server;
ssl_certificate /etc/letsencrypt/live/EXMAPLE.EXMAPLE/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/EXMAPLE.EXMAPLE/privkey.pem;
return 444;
}
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Verification Checklist
- HTTP correctly redirects to HTTPS
- Site loads only via HTTPS
- Direct IP access is blocked
- Security headers are present (Dev Tools -> Network -> Refresh page -> click on / -> Response)
- SSL Labs score is A+ (Optional)https://www.ssllabs.com/ssltest/
Enable Cloudflare Proxy
Turn Proxy ON (orange cloud) for both A records in DNS Management.