Custom Nginx Docker Image With TLSv1.3, GEOIP2 & RTMP Support

Introduction

In this tutorial, you will learn how to build an Nginx Docker image with support for GEOIP2 and NGINX RTMP Media Streaming Server, based on the official Nginx Dockerfile. The official image provided by Docker Hub has limited functionality, therefore we will extend it to support other needed features.

Prerequisites

  1. Root access to your server, or a non-root user with sudo privileges.
  2. Docker and Docker Compose already installed in the host machine.

The Nginx Source Docker File

Now, we will navigate to the Linux Alpine Nginx based Dockerfile, and examine what we see there. For your own convenience this is the code for the most recent Nginx Version:

FROM alpine:3.13

LABEL maintainer="NGINX Docker Maintainers <docker-maint@nginx.com>"

ENV NGINX_VERSION 1.19.8
ENV NJS_VERSION   0.5.1
ENV PKG_RELEASE   1

RUN set -x \
# create nginx user/group first, to be consistent throughout docker variants
    && addgroup -g 101 -S nginx \
    && adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx \
    && apkArch="$(cat /etc/apk/arch)" \
    && nginxPackages=" \
        nginx=${NGINX_VERSION}-r${PKG_RELEASE} \
        nginx-module-xslt=${NGINX_VERSION}-r${PKG_RELEASE} \
        nginx-module-geoip=${NGINX_VERSION}-r${PKG_RELEASE} \
        nginx-module-image-filter=${NGINX_VERSION}-r${PKG_RELEASE} \
        nginx-module-njs=${NGINX_VERSION}.${NJS_VERSION}-r${PKG_RELEASE} \
    " \
    && case "$apkArch" in \
        x86_64) \
# arches officially built by upstream
            set -x \
            && KEY_SHA512="e7fa8303923d9b95db37a77ad46c68fd4755ff935d0a534d26eba83de193c76166c68bfe7f65471bf8881004ef4aa6df3e34689c305662750c0172fca5d8552a *stdin" \
            && apk add --no-cache --virtual .cert-deps \
                openssl \
            && wget -O /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub \
            && if [ "$(openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout | openssl sha512 -r)" = "$KEY_SHA512" ]; then \
                echo "key verification succeeded!"; \
                mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/; \
            else \
                echo "key verification failed!"; \
                exit 1; \
            fi \
            && apk del .cert-deps \
            && apk add -X "https://nginx.org/packages/mainline/alpine/v$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)/main" --no-cache $nginxPackages \
            ;; \
        *) \
# we're on an architecture upstream doesn't officially build for
# let's build binaries from the published packaging sources
            set -x \
            && tempDir="$(mktemp -d)" \
            && chown nobody:nobody $tempDir \
            && apk add --no-cache --virtual .build-deps \
                gcc \
                libc-dev \
                make \
                openssl-dev \
                pcre-dev \
                zlib-dev \
                linux-headers \
                libxslt-dev \
                gd-dev \
                geoip-dev \
                perl-dev \
                libedit-dev \
                mercurial \
                bash \
                alpine-sdk \
                findutils \
            && su nobody -s /bin/sh -c " \
                export HOME=${tempDir} \
                && cd ${tempDir} \
                && hg clone https://hg.nginx.org/pkg-oss \
                && cd pkg-oss \
                && hg up ${NGINX_VERSION}-${PKG_RELEASE} \
                && cd alpine \
                && make all \
                && apk index -o ${tempDir}/packages/alpine/${apkArch}/APKINDEX.tar.gz ${tempDir}/packages/alpine/${apkArch}/*.apk \
                && abuild-sign -k ${tempDir}/.abuild/abuild-key.rsa ${tempDir}/packages/alpine/${apkArch}/APKINDEX.tar.gz \
                " \
            && cp ${tempDir}/.abuild/abuild-key.rsa.pub /etc/apk/keys/ \
            && apk del .build-deps \
            && apk add -X ${tempDir}/packages/alpine/ --no-cache $nginxPackages \
            ;; \
    esac \
# if we have leftovers from building, let's purge them (including extra, unnecessary build deps)
    && if [ -n "$tempDir" ]; then rm -rf "$tempDir"; fi \
    && if [ -n "/etc/apk/keys/abuild-key.rsa.pub" ]; then rm -f /etc/apk/keys/abuild-key.rsa.pub; fi \
    && if [ -n "/etc/apk/keys/nginx_signing.rsa.pub" ]; then rm -f /etc/apk/keys/nginx_signing.rsa.pub; fi \
# Bring in gettext so we can get `envsubst`, then throw
# the rest away. To do this, we need to install `gettext`
# then move `envsubst` out of the way so `gettext` can
# be deleted completely, then move `envsubst` back.
    && apk add --no-cache --virtual .gettext gettext \
    && mv /usr/bin/envsubst /tmp/ \
    \
    && runDeps="$( \
        scanelf --needed --nobanner /tmp/envsubst \
            | awk '{ gsub(/,/, "\nso:", $2); print "so:" $2 }' \
            | sort -u \
            | xargs -r apk info --installed \
            | sort -u \
    )" \
    && apk add --no-cache $runDeps \
    && apk del .gettext \
    && mv /tmp/envsubst /usr/local/bin/ \
# Bring in tzdata so users could set the timezones through the environment
# variables
    && apk add --no-cache tzdata \
# Bring in curl and ca-certificates to make registering on DNS SD easier
    && apk add --no-cache curl ca-certificates \
# forward request and error logs to docker log collector
    && ln -sf /dev/stdout /var/log/nginx/access.log \
    && ln -sf /dev/stderr /var/log/nginx/error.log \
# create a docker-entrypoint.d directory
    && mkdir /docker-entrypoint.d

COPY docker-entrypoint.sh /
COPY 10-listen-on-ipv6-by-default.sh /docker-entrypoint.d
COPY 20-envsubst-on-templates.sh /docker-entrypoint.d
COPY 30-tune-worker-processes.sh /docker-entrypoint.d
ENTRYPOINT ["/docker-entrypoint.sh"]

EXPOSE 80

STOPSIGNAL SIGQUIT

CMD ["nginx", "-g", "daemon off;"]

As you can see, building an application and removing afterward all dependencies and such in Linux Alpine, is pretty complicated.

Final Nginx Dockerfile With GEOIP2, RTMP & TLSv1.3 Support

FROM alpine:latest

LABEL maintainer="Nikolas S <nikolas@admintuts.net>"
# Host Specific Variable
ARG NGINX_GUI=2000
ENV NGINX_GUI $NGINX_GUI

# ngx_http_geoip2_module & libmaxminddb installation

ENV MAXMIND_VERSION=1.6.0
COPY GeoLite2-Country.mmdb /usr/share/geoip/

RUN set -x \
  && apk add --no-cache --virtual .build-deps \
    alpine-sdk \
    perl \
  && git clone https://github.com/leev/ngx_http_geoip2_module /ngx_http_geoip2_module \
  && wget https://github.com/maxmind/libmaxminddb/releases/download/${MAXMIND_VERSION}/libmaxminddb-${MAXMIND_VERSION}.tar.gz \
  && tar xf libmaxminddb-${MAXMIND_VERSION}.tar.gz \
  && cd libmaxminddb-${MAXMIND_VERSION} \
  && ./configure \
  && make \
  && make check \
  && make install \
  && apk del .build-deps

RUN ldconfig || :

# Nginx installation 

ENV NGINX_VERSION 1.21.0
RUN GPG_KEYS=B0F4253373F8F6F510D42178520A9993A1C052F8 \
&& CONFIG="\
    --prefix=/etc/nginx \
    --sbin-path=/usr/sbin/nginx \
    --modules-path=/usr/lib/nginx/modules \
    --conf-path=/etc/nginx/nginx.conf \
    --error-log-path=/var/log/nginx/error.log \
    --http-log-path=/var/log/nginx/access.log \
    --pid-path=/var/run/nginx.pid \
    --lock-path=/var/run/nginx.lock \
    --http-client-body-temp-path=/var/cache/nginx/client_temp \
    --http-proxy-temp-path=/var/cache/nginx/proxy_temp \
    --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \
    --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \
    --http-scgi-temp-path=/var/cache/nginx/scgi_temp \
    --user=nginx \
    --group=nginx \
    --with-http_ssl_module \
    --with-http_realip_module \
    --with-http_addition_module \
    --with-http_sub_module \
    --with-http_dav_module \
    --with-http_flv_module \
    --with-http_mp4_module \
    --with-http_gunzip_module \
    --with-http_gzip_static_module \
    --with-http_random_index_module \
    --with-http_secure_link_module \
    --with-http_stub_status_module \
    --with-http_auth_request_module \
    --with-http_xslt_module=dynamic \
    --with-http_image_filter_module=dynamic \
    --with-http_geoip_module=dynamic \
    --with-threads \
    --with-stream \
    --with-stream_ssl_module \
    --with-stream_ssl_preread_module \
    --with-stream_realip_module \
    --with-stream_geoip_module=dynamic \
    --with-http_slice_module \
    --with-mail \
    --with-mail_ssl_module \
    --with-compat \
    --with-file-aio \
    --with-http_v2_module \
    --with-openssl-opt="enable-tls1_3" \
    --with-openssl-opt=no-nextprotoneg \
    --add-dynamic-module=/ngx_http_geoip2_module \
    --add-dynamic-module=/nginx-rtmp-module \
" \
    && addgroup -S nginx -g $NGINX_GUI \
    && adduser -D -S -h /var/cache/nginx -s /sbin/nologin -G nginx -u $NGINX_GUI nginx \
    && apk add --no-cache --virtual .build-deps \
        gcc \
        git \
        libc-dev \
        make \
        openssl-dev \
        pcre-dev \
        zlib-dev \
        linux-headers \
        curl \
        gnupg1 \
        libxslt-dev \
        gd-dev \
        geoip-dev \
    && curl -fSL https://nginx.org/download/nginx-$NGINX_VERSION.tar.gz -o nginx.tar.gz \
    && curl -fSL https://nginx.org/download/nginx-$NGINX_VERSION.tar.gz.asc  -o nginx.tar.gz.asc \
    && export GNUPGHOME="$(mktemp -d)" \
    && found=''; \
    for server in \
        ha.pool.sks-keyservers.net \
        p80.pool.sks-keyservers.net \
        keyserver.ubuntu.com \
        pgp.mit.edu \
    ; do \
        echo "Fetching GPG key $GPG_KEYS from $server"; \
        gpg --keyserver "$server" --keyserver-options timeout=10 --recv-keys "$GPG_KEYS" && found=yes && break; \
    done; \
    test -z "$found" && echo >&2 "error: failed to fetch GPG key $GPG_KEYS" && exit 1; \
    gpg --batch --verify nginx.tar.gz.asc nginx.tar.gz \
    && rm -rf "$GNUPGHOME" nginx.tar.gz.asc \
    && mkdir -p /usr/src \
    && git clone https://github.com/sceptic30/nginx-rtmp-module.git \
    && tar -zxC /usr/src -f nginx.tar.gz \
    && rm nginx.tar.gz \
    && cd /usr/src/nginx-$NGINX_VERSION \
    && ./configure $CONFIG --with-debug \
    && make -j$(getconf _NPROCESSORS_ONLN) \
    && mv objs/nginx objs/nginx-debug \
    && mv objs/ngx_http_xslt_filter_module.so objs/ngx_http_xslt_filter_module-debug.so \
    && mv objs/ngx_http_image_filter_module.so objs/ngx_http_image_filter_module-debug.so \
    && mv objs/ngx_http_geoip_module.so objs/ngx_http_geoip_module-debug.so \
    && mv objs/ngx_stream_geoip_module.so objs/ngx_stream_geoip_module-debug.so \
    && ./configure $CONFIG \
    && make -j$(getconf _NPROCESSORS_ONLN) \
    && make install \
    && rm -rf /etc/nginx/html/ \
    && mkdir /etc/nginx/conf.d/ \
    && mkdir -p /usr/share/nginx/html/ \
    && install -m644 html/index.html /usr/share/nginx/html/ \
    && install -m644 html/50x.html /usr/share/nginx/html/ \
    && install -m755 objs/nginx-debug /usr/sbin/nginx-debug \
    && install -m755 objs/ngx_http_xslt_filter_module-debug.so /usr/lib/nginx/modules/ngx_http_xslt_filter_module-debug.so \
    && install -m755 objs/ngx_http_image_filter_module-debug.so /usr/lib/nginx/modules/ngx_http_image_filter_module-debug.so \
    && install -m755 objs/ngx_http_geoip_module-debug.so /usr/lib/nginx/modules/ngx_http_geoip_module-debug.so \
    && install -m755 objs/ngx_stream_geoip_module-debug.so /usr/lib/nginx/modules/ngx_stream_geoip_module-debug.so \
    && install -m755 objs/ngx_stream_geoip_module-debug.so /usr/lib/nginx/modules/ngx_stream_geoip_module-debug.so \
    && ln -s ../../usr/lib/nginx/modules /etc/nginx/modules \
    && strip /usr/sbin/nginx* \
    && strip /usr/lib/nginx/modules/*.so \
    && rm -rf /usr/src/nginx-$NGINX_VERSION \
    && rm -rf /usr/src/nginx-rtmp-module \
    && cd /                 \
    && rm -rf libmaxminddb-${MAXMIND_VERSION} \
    && rm -rf libmaxminddb-${MAXMIND_VERSION}.tar.gz \
    && rm -rf ngx_http_geoip2_module \
    && rm -rf nginx-rtmp-module \
    \
    # Bring in gettext so we can get `envsubst`, then throw
    # the rest away. To do this, we need to install `gettext`
    # then move `envsubst` out of the way so `gettext` can
    # be deleted completely, then move `envsubst` back.
    && apk add --no-cache --virtual .gettext gettext \
    && mv /usr/bin/envsubst /tmp/ \
    \
    && runDeps="$( \
        scanelf --needed --nobanner --format '%n#p' /usr/sbin/nginx /usr/lib/nginx/modules/*.so /tmp/envsubst \
            | tr ',' '\n' \
            | sort -u \
            | awk 'system("[ -e /usr/local/lib/" $1 " ]") == 0 { next } { print "so:" $1 }' \
    )" \
    && apk add --no-cache --virtual .nginx-rundeps $runDeps \
    && apk del .build-deps \
    && apk del .gettext \
    && mv /tmp/envsubst /usr/local/bin/ \
    \
    # Bring in tzdata and openssl so users could set the timezones and tls1.3 through the environment
    # variables
    && apk add --no-cache tzdata \
    && mkdir /docker-entrypoint.d \
    && apk add --no-cache curl ca-certificates \
    # Create access and error logging
    && touch /var/log/nginx/access.log \
    && touch /var/log/nginx/error.log \ 
    # forward request and error logs to docker log collector
    && ln -sf /dev/stdout /var/log/nginx/access.log \
    && ln -sf /dev/stderr /var/log/nginx/error.log \
    # Give nginx users appropriate permissions to eccential directories recursively
    && mkdir /etc/letsencrypt \
    && mkdir /var/lib/letsencrypt \
    && mkdir /var/log/letsencrypt \
    && mkdir -p /var/www/html \
    && chown -R nginx:nginx /var/log \
    && chown -R nginx:nginx /var/cache/nginx \
    && chown -R nginx:nginx /usr/share/nginx \
    && chown -R nginx:nginx /etc/nginx \
    && chown -R nginx:nginx /var/www \
    && chown -R root:nginx /etc/letsencrypt \
    && touch /var/run/nginx.pid  \
    && chown nginx:nginx /var/run/nginx.pid \
    && chmod 770 /var/run/nginx.pid \
    && chown root:nginx /run \
    && chmod 770 -R /run \
    && chmod 755 /etc/nginx \
    && chmod 755 -R /var/log/nginx \
    && chmod 770 -R /var/log/letsencrypt \
    && chmod 755 -R /usr/share/nginx \
    && chmod 755 -R /etc/nginx/conf.d \
    && chmod 755 -R /var/www

COPY nginx.conf /etc/nginx/nginx.conf
COPY vh-default.conf /etc/nginx/conf.d/default.conf
COPY docker-entrypoint.sh /
COPY envsubst-on-templates.sh /docker-entrypoint.d
COPY tune-worker-processes.sh /docker-entrypoint.d
ENTRYPOINT ["/docker-entrypoint.sh"]
USER nginx
EXPOSE 3080 3443
STOPSIGNAL SIGTERM
CMD ["nginx", "-g", "daemon off;"]

In order to use this Dockerfile correctly, I created a GitHub repo which you can use by issuing the command:

git clone https://github.com/sceptic30/nginx-rtmp-geoip2-alpine.git
cd nginx-rtmp-geoip2-alpine
docker build . -t image_name

For flexibility reasons, the Nginx RTMP module, and GEOIP2 are built as dynamic modules, which means that in order to enable them, we will have to include them using the load_module directive in the nginx.conf file as follows:

load_module /etc/nginx/modules/ngx_http_geoip2_module.so;
load_module /etc/nginx/modules/ngx_rtmp_module.so;

This will give you the flexibility to use the same image without one, or both, of the above modules, but keep all the rest. For example, the ngx_stream_core_module, and with-stream_ssl_preread are not included by default in the official Nginx Dockerfile. are included in the repo I linked above.

For instance, at the previous tutorial about tcp load balancing with nginx the image used was the exact same one built with the code above. That’s because the official Nginx image does not support TCP load balancing by default.

How To Enable TLSv1.3 In Nginx

In order for TLSv1.3 to work, SSL configuration details MUST be stated in every server block. That’s because the TLS connections are not upgraded like for instance the “connection upgrade” in Websockets. If the very first block of your Nginx config does not enable TLSv1.3, then it will default to TLSv1.2 even if the last server block that all requests gets eventually redirected, supports it.  Below you will find a sample configuration, that will work outside of the box.

server {
	server_name xxx.xxx.xxx.xxx; #Your current server ip address. It will redirect to the domain name.
	listen 80;
	listen 443 ssl http2;
	include ssl_config;
	return 301 https://example.com$request_uri;
}
server {
    server_name www.example.com;
    listen 80;
    listen 443 ssl http2;
    listen [::]:80;
    listen [::]:443 ssl http2;
	include ssl_config;
    # Non-www redirect
    return 301 https://example.com$request_uri;
}
server {
    server_name example.com;
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    root /var/www/html;
    charset UTF-8;
	include ssl_config;
location ~* \.(jpg|jpe?g|gif|png|ico|cur|gz|svgz|mp4|ogg|ogv|webm|htc|css|js|otf|eot|svg|ttf|woff|woff2)(\?ver=[0-9.]+)?$ {
    expires max;
    add_header Access-Control-Allow-Origin '*';
    add_header Cache-Control "public, must-revalidate, proxy-revalidate";
    access_log off;
    }
    #access_log  logs/host.access.log  main;
    location ~ /.well-known/acme-challenge {
      allow all;
      root /var/www/html;
      default_type "text/plain";
    }
location / {
    index index.php;
    try_files $uri $uri/ /index.php?$args;
    #limit_conn num_conn 15;
    #limit_req zone=num_reqs;
    }
error_page  404    /404.php;
#pass the PHP scripts to FastCGI server listening on php-fpm unix socket
location ~ \.php$ {
    include fastcgi_config;
}
location = /robots.txt {
    access_log off;
    log_not_found off;
    }
location ~ /\. {
    deny  all;
    access_log off;
    log_not_found off;
    }
}

create a file ssl_config, and paste the configuration below:

add_header Strict-Transport-Security 'max-age=31536000; includeSubDomains; preload';
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy no-referrer;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers TLS13-CHACHA20-POLY1305-SHA256:TLS13-AES-256-GCM-SHA384:TLS13-AES-128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_session_tickets on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_ecdh_curve auto;
keepalive_timeout   70;
ssl_buffer_size 1400;
ssl_dhparam ssl/dhparam.pem;
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=86400;
resolver_timeout 10;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/fullchain.pem;

You will have to create ssl_dhparam file, as described in the LetsEncrypt tutorial.

Next, create a fastcgi_config file, and paste the contents:

try_files       $uri =404;
fastcgi_index   index.php;
fastcgi_pass    wordpress:9000;
fastcgi_pass_request_headers on;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_intercept_errors on;
fastcgi_ignore_client_abort off;
fastcgi_connect_timeout 60;
fastcgi_send_timeout 180;
fastcgi_read_timeout 180;
fastcgi_request_buffering on;
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
fastcgi_temp_file_write_size 256k;
include fastcgi_params;

Be aware that fastcgi_pass wordpress:9000;is the docker hostname of the container that handles the php connections-requests. In this example is WordPress. In your case could be fastcgi_pass php:9000;

Nginx Docker Image Download

If you don’t want to build the image yourself, or you don’t have the proper building environment set up, I have built the image and uploaded it to the Admintuts Docker Hub repository.

To download the Mainline version, use the command:

docker pull admintuts/nginx:1.21.0-rtmp-geoip2-alpine

To download the Stable version, use the command:

docker pull admintuts/nginx:1.20.1-rtmp-geoip2-alpine