Nginx: Optimizar y Securizar un despliegue de WordPress

Buenas, en este post, vamos a hablar de cómo desplegar Nginx con una configuración segura y optimizada para un backend de WordPress con PHP-FPM.

Lo primero, es tener clara la arquitectura que vamos a utilizar. En este caso será: Nginx –> WordPress (PHP-FPM) –> MySQL

Todo el stack, lo desplegaremos con docker y docker-compose por simpleza y rapidez. Además de esto, usaremos traefik como proxy web y hacer el offloading de SSL. No vamos a entrar como instalar y configurar docker, docker-compose o traefik por que ya está hecho en posts anteriores.

Nginx

Para empezar, vamos a crear el docker-compose.yaml con el servicio de Nginx. Vamos a añadirle; healthchecks, limites de recursos y que los parámetros vayan por variables. De este modo, se puede reutilizar para diferentes sites sin tener que cambiar nada de la configuración.

...
web:
    image: nginx
    restart: always
    container_name: ${SITE_NAME}-web
    environment:
      - TZ=${TZ}
    volumes:
      - wordpress:/var/www/html
      - ./etc/nginx/conf.d:/etc/nginx/conf.d:ro
      - ./etc/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - /var/log/nginx/${SITE_NAME}:/var/log/nginx
    networks:
      - net
      - traefik
    depends_on:
      - php-fpm
    healthcheck:
      test: pidof nginx
    deploy:
      resources:
        limits:
          cpus: '0.2'
          memory: '500m'
    labels:
      - "traefik.backend=${SITE_NAME}"       
      - "traefik.frontend.rule=Host:${SITE_URL}"       
      - "traefik.docker.network=traefik"       
      - "traefik.port=80"
      - "traefik.frontend.passHostHeader=true"
      - "docker.group=${SITE_NAME}"
...

Esto sería en cuanto al docker-compose.yaml. También hay que crear los ficheros de configuración. En este caso, como están distribuidos es de la siguiente manera.

./etc/
  |--> nginx/
         |--> nginx.conf
         |--> conf.d/
                |--> headers.conf
                |--> default.conf
                |--> cache.conf

De esta forma, la configuración de Nginx, irá sobre el fichero nginx.conf y toda la configuración extra, irá sobre conf.d/. Vamos a explicar un poco las partes mas significativas de las configuraciones que vamos a utilizar.

/etc/nginx/nginx.conf

https://github.com/ichasco/Docker-Wordpress/blob/master/etc/nginx/nginx.conf

Deshabilitar las cabeceras inválidas y la versión de nginx

ignore_invalid_headers      on;
server_tokens               off;

Evitar ataques de buffer overflow

client_body_buffer_size     100K;
client_header_buffer_size   1k;
client_max_body_size        100k;
large_client_header_buffers 2 1k;

Controlar Time-outs

client_body_timeout         15;
client_header_timeout       15;
send_timeout                15;
keepalive_timeout           5 5;

OCSP Stapling

ssl_stapling                on;
ssl_stapling_verify         on;
resolver                    1.1.1.1 208.67.222.222 208.67.220.220 valid=60s;
resolver_timeout            2s;

/etc/nginx/conf.d/cache.conf

https://github.com/ichasco/Docker-Wordpress/blob/master/etc/nginx/conf.d/cache.conf

Vamos a añadir una cache para el FastCGI que cachee todas la peticiones y solo pase al back una para actualizar la cache. De este modo reducimos mucho la carga del back optimizando las peticiones e incrementando el rendimiento.

add_header 		       X-FastCGI-Cache $upstream_cache_status;

fastcgi_buffering 	       on;
fastcgi_cache 		cache;
fastcgi_ignore_headers 	Cache-Control Expires;
fastcgi_cache_lock 	       on;
fastcgi_cache_min_uses 	1;
fastcgi_cache_use_stale     error timeout updating invalid_header http_500 http_503;
fastcgi_cache_valid 	       200 301 302 30m;

/etc/nginx/conf.d/headers.conf

https://github.com/ichasco/Docker-Wordpress/blob/master/etc/nginx/conf.d/headers.conf

En el apartado de headers, vamos añadir todas la headers recomendadas para evitar posibles ataques.

## Headers ## 

fastcgi_hide_header         X-Powered-By;
fastcgi_hide_header         X-Pingback;
fastcgi_hide_header 	       Link;

add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; 
add_header X-Frame-Options "SAMEORIGIN"; 
add_header X-Content-Type-Options "nosniff" always; 
add_header X-XSS-Protection "1; mode=block" always; 
add_header Referrer-Policy 'same-origin'; 
add_header X-Permitted-Cross-Domain-Policies none; 
add_header Expect-CT "enforce, max-age=300"; 
# Esta Cabecera hay que adaptarla al contenido de la web #
add_header Content-Security-Policy "default-src 'self'; connect-src *; font-src *; frame-src *; img-src * data:; media-src *; object-src *; script-src * 'unsafe-inline' 'unsafe-eval'; style-src * 'unsafe-inline';";

/etc/nginx/conf.d/default.conf

https://github.com/ichasco/Docker-Wordpress/blob/master/etc/nginx/conf.d/default.conf

Por último, hay que crear la configuración del site.

Para empezar, añadir el path de la cache y la key que vamos a utilizar

fastcgi_cache_path 	/tmp/cache keys_zone=cache:256m levels=1:2 inactive=600s max_size=100m;
fastcgi_cache_key 	"$scheme$request_method$host$request_uri";

Ahora, añadir el upstream. Este será el nombre del servicio de wordpress que vamos a crear luego. En este caso, este servicio se llamará php-fpm y el puerto correspondiente.

upstream php {
    server php-fpm:9000;
}

A continuación, vamos a crear los location de / y de todos los archivos con extensión .php que vayan por el proxypass al servicio de php-fpm y añadiremos la caché.

location / {
        try_files $uri $uri/ /index.php?$query_string;
    
        # Add Headers conf # 
        include /etc/nginx/conf.d/headers.conf;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        # Upstream
        fastcgi_pass php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME     $request_filename;
        fastcgi_read_timeout 600;

        # Add Headers conf #
        include /etc/nginx/conf.d/headers.conf;

        # Add Cache Settings #
        include /etc/nginx/conf.d/cache.conf;
	 set $skip_cache 0;
	
        # POST requests and urls with a query string should always go to PHP
        if ($request_method = POST) {
            set $skip_cache 1;
        }

        if ($query_string != "") {
            set $skip_cache 1;
        }
        
        # Don't cache uris containing the following segments
        if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|^/feed/*|/tag/.*/feed/*|index.php|/.*sitemap.*\.(xml|xsl)") {
            set $skip_cache 1;
        }
        
        # Don't use the cache for logged in users or recent commenters
        if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
            set $skip_cache 1;
        }

        fastcgi_cache_bypass $skip_cache;
        fastcgi_no_cache $skip_cache;
    }

Configurar para que el navegador cachee también los archivos multimedia

location ~* \.(?:jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
        expires 7d;
        access_log off;
        add_header Cache-Control "public";
}
    
location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff|woff2)$ {
        add_header Access-Control-Allow-Origin "*";
        expires 7d;
        access_log off;
}

Denegar acceso a todos los archivos ocultos

location ~ /\. {
        access_log off;
        log_not_found off;
        deny all;
}

Configurar la seguridad de WordPress a nivel de Servidor Web.
¡Esto puede dar problemas dependiendo de la configuración y de los plugins que se tenga!

# WordPress: deny wp-content, wp-includes php files
location ~* ^/(?:wp-content|wp-includes)/.*\.php$ {
        deny all;
}
    
# WordPress: deny wp-content/uploads nasty stuff
location ~* ^/wp-content/uploads/.*\.(?:s?html?|php|js|swf)$ {
        deny all;
}
    
# WordPress: deny wp-content/plugins nasty stuff
location ~* ^/wp-content/plugins/.*\.(?!css(\.map)?|js(\.map)?|ttf|ttc|otf|eot|woff|woff2|svgz?|jpe?g|png|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv|pdf|docx?|xlsx?|pptx?) {
        deny all;
}
    
# WordPress: deny scripts and styles concat
    location ~* \/wp-admin\/load-(?:scripts|styles)\.php {
        deny all;
}
    
# WordPress: deny general stuff
location ~* ^/(?:xmlrpc\.php|wp-links-opml\.php|wp-config\.php|wp-config-sample\.php|wp-comments-post\.php|readme\.html|license\.txt)$ {
        deny all;
}

Bloquear Spammers

if ( $http_referer ~* (babes|forsale|girl|jewelry|love|nudit|organic|poker|porn|sex|teen) ){
        return 403;
}

Con estas configuraciones, tendríamos el Nginx configurado para que rinda al máximo de una forma segura.

PHP-FPM

El servicio de PHP-FPM, va a incluir el WordPress. Para este, utilizaremos la imagen oficial de WordPress con php-fpm.

Para empezar, vamos a editar el docker-compose.yaml y añadir el servicio.

...
  php-fpm:
    image: wordpress:php7.4-fpm
    container_name: ${SITE_NAME}-php
    restart: always
    environment:
      - WORDPRESS_DB_USER=${MYSQL_USER}
      - WORDPRESS_DB_PASSWORD=${MYSQL_PASSWORD}
      - WORDPRESS_DB_HOST=mysql
      - WORDPRESS_DB_NAME=${MYSQL_DATABASE}
      - TZ=${TZ}
    volumes:
      - wordpress:/var/www/html
      - ./etc/php/conf.d/uploads.ini:/usr/local/etc/php/conf.d/uploads.ini:ro
    networks:
      - net
    depends_on:
      - mysql
    healthcheck:
      test: pidof php-fpm
    deploy:
      resources:
        limits:
          cpus: '0.2'
          memory: '500m'
    labels:
      - "traefik.enable=false"
      - "docker.group=${SITE_NAME}"
...

Y en este caso, solo vamos a pasar un fichero, que es el que limita las subidas de archivos.

/etc/php/conf.d/uploads.ini

https://github.com/ichasco/Docker-Wordpress/blob/master/etc/php/conf.d/uploads.ini
file_uploads = On
memory_limit = 64M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 600

MariaDB

Y ya por último, nos quedaría configurar la base de datos. Para esto, volvemos a editar el docker-compose.yaml y añadimos el servicio de mariadb. En este caso, se opta por mariadb por rendimiento superior a mysql.

...
mysql:
    image: mariadb:10.3
    restart: always
    container_name: ${SITE_NAME}-db
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_ALLOW_EMPTY_PASSWORD=no
      - TZ=${TZ}
    volumes:
      - db:/var/lib/mysql
    networks:
      - net
    healthcheck:
      test: mysqladmin ping -h localhost -p$$MYSQL_ROOT_PASSWORD && test '0' -eq $$(ps aux | awk '{print $$11}' | grep -c -e '^mysql$$')
    deploy:
      resources:
        limits:
          cpus: '0.2'
          memory: '500m'
    labels:
      - "traefik.enable=false"
      - "docker.group=${SITE_NAME}"
...

Este servicio no requiere archivos de configuración ni nada por el estilo. Ahora nos quedaría configurar en el docker-compose.yaml los volúmenes y las redes que vamos a utilizar.

volumes:
  db:
  wordpress:

networks:
  net:
  traefik:
    external:
      name: traefik

Y por último, hay que configurar las variables que queramos en el archivo .env

## GENERAL ##
TZ=Europe/Madrid

## WEB ##
SITE_NAME=example
SITE_URL=www.example.com

## MYSQL ##
MYSQL_ROOT_PASSWORD=root_password
MYSQL_USER=wp_usr
MYSQL_PASSWORD=wp_password
MYSQL_DATABASE=wp_db

Y una vez hecho todo esto, ya podemos levantar todo el stack de WordPress

docker-compose --compatibility up -d

Todos los archivos, se pueden encontrar en el repositorio de Github

Y ahora que estaría desplegado todo, podemos comprobar el estado de la web en: https://observatory.mozilla.org

Estos son los consejos para mejorar un despliegue de Worpdress bajo Nginx y PHP-FPM. Si se os ocurre algún consejo mas, siempre es bienvenido 😉


Also published on Medium.

Leave a Reply

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *