Skip to content

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.