Every time I have a new computer whether it be a MacBook or PC (PopOS). I have to install MAMP / XAMP with some brew installations, without a clue where all those files being installed. Sometimes you want to run different PHP version depending on a project or perhaps you want to revive legacy ones, run https SSL locally because WebRTC only works secured environment or even attach a Node project with socket file.
And you don't want to pollute your machine with development files unless containerized.

Docker is my man!

For this guide I let you install some Docker containers, working with Nginx configurations, connect containers, run docker commands, have SSL Certificates on your localhost.

  • Alpine
  • NGINX
  • PHP FPM 5.6.23 / 7.0.8 / 8.0.0
  • MariaDB (MySQL)
  • Phpmyadmin

Also:

  • PHP FPM with extra modules
  • Self-signed Certificates for localhost
  • Letsencrypt automation
- You can skip the whole article and scroll down to download from my github repository.
- This article expects you already have Docker installed on your machine and have root privileges and know some terminal commands. This article is base on a PopOS/Debian computer.
- Tip: Post-installation steps for Linux to run docker without sudo
Article Updates
  • 2021-1-29 # Changed File Structure build and data
  • 2021-1-1 #
      Beter naming convention;
      Letsencrypt and automation
  • 2020-12-19 #
      Dockerfile PHP 8 FPM
  • 2020-12-18 #
      Improved docker-compose.yml for database and phpmyadmin

Let’s create some files

Create docker-compose.yml

version: '2'

services:
  nginx:
    image: nginx:alpine
    restart: always
    links:
      - 5-6-23-fpm
      - 7-0-8-fpm
      - 8-0-0-fpm
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./data/nginx/enabled:/etc/nginx/conf.d
      - ./data/nginx/snippets:/nginx/snippets
      - ./data/nginx/certificates:/nginx/certificates
    volumes_from:
      - data

  5-6-23-fpm:
    image: php:5.6.23-fpm-alpine
    restart: always
    volumes_from:
      - data

  7-0-8-fpm:
    image: php:7.0.8-fpm-alpine
    restart: always
    volumes_from:
      - data
      
  8-0-0-fpm:
    image: php:8.0.0-fpm-alpine
    restart: always
    volumes_from:
      - data

  db:
    image: madiadb #v10.5.8
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
    volumes:
      - ./data/db:/var/lib/mysql

  dbadmin:
    image: phpmyadmin/phpmyadmin
    restart: always
    environment:
      PMA_HOST: db
      PMA_USER: root # Remove line for production
      PMA_PASSWORD: root # Remove line for production
    depends_on:
      - db
    # ports:
    #   - "8080:80"

  data:
    image: alpine:latest
    command: echo "READY!!!"
    volumes:
      - ./data/vhosts:/vhosts
      - ./data/tmp:/tmp
file: /var/docker/docker-xnmp-vhosts/docker-compose.yml

Then run command from /var/docker/docker-xnmp-vhosts/ directory.

# Start docker containers from compose file
docker-compose up
Run commands from: /var/docker/docker-xnmp-vhosts/
You can also run this as background process: docker-compose up -d

From here if you check volumes some directories are created, and currently the port 80 and 443 are available from localhost. http://localhost

Upon visiting the url, there's actually nothing to see except an 404 error page

Create simple page for Default vhosts

<!DOCTYPE html>  
<html>  
<head>  
	<title>DEFAULT DOMAIN</title>
    <style>
        body {
            background: black;
            color: hotpink;
        }
    </style>
</head>
<body>
	This is default domain
</body>
</html>
file: /var/docker/docker-xnmp-vhosts/data/vhosts/_default_/httpdocs/index.html

Create Nginx Default configuration

server {
	listen 80 default_server;
	listen [::]:80 default_server;

	server_name _;

	index index.html index.htm;
	root /vhosts/_default_/httpdocs;
	location / {
		# First attempt to serve request as file, then
		# as directory, then fall back to displaying a 404.
		try_files $uri $uri/ /index.php$is_args$args =404;
	}
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/_default_.conf

Restart NGINX

Sinds we know our docker containers are still running, we can run the command from
/var/docker/docker-xnmp-vhosts/ directory:

# Restart Nginx
docker-compose exec nginx nginx -t && docker-compose restart nginx
Run commands from: /var/docker/docker-xnmp-vhosts/
This command will use already running nginx container and execute a command nginx -t for testing valid configuration and restart only nginx container.
nginx - is the service name we gave in the docker-compose.yml file.
nginx -t - is the command that are available in the nginx container.
Every changes you make in the .conf file. You need to restart your nginx server to take effect.

After web server restart we can visit the page: http://localhost

Virtual Hosts (VHOSTS)

Let’s create a file structure for example.com with two sub-domains in mind for example: antique and beauty.

/var/docker/docker-xnmp-vhosts/
  data/
    nginx/
      enabled/example.com.conf
    vhosts/
      example.com/
        httpdocs/index.html
        subdomains/
          antique/httpdocs/index.html
          beauty/httpdocs/index.html
File Structure
After good configuration, you can add as many subdomains as you wish without the need to restart Nginx

Vhost: example.com

Virtual #1 - example.com

<!DOCTYPE html>  
<html>  
<head>  
	<title>Example</title>
    <style>
        body {
            background: lightgrey;
            color: cadetblue;
        }
    </style>
</head>
<body>
	Example domain
</body>
</html>
file: /var/docker/docker-xnmp-vhosts/data/vhosts/example.com/httpdocs/index.html

Virtual #2 - antique.example.com

<!DOCTYPE html>  
<html>  
<head>  
	<title>Antique Example</title>
    <style>
        body {
            background: lightslategrey;
            color: lightblue;
        }
    </style>
</head>
<body>
	Antique subdomain
</body>
</html>
file: /var/docker/docker-xnmp-vhosts/data/vhosts/example.com/subdomains/antique/httpdocs/index.html

Virtual #3 - beauty.example.com

<!DOCTYPE html>  
<html>  
<head>  
	<title>Beauty Example</title>
    <style>
        body {
            background: lightslategrey;
            color: lightblue;
        }
    </style>
</head>
<body>
	Beauty subdomain
</body>
</html>
file: /var/docker/docker-xnmp-vhosts/data/vhosts/example.com/subdomains/beauty/httpdocs/index.html

Nginx configuration: example.com

# domain: example.com
server {
	disable_symlinks off;
	server_name ~^example\.com(\.localhost)?$;

	root /vhosts/example.com/httpdocs;

	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri/ /index.php?$query_string =404;
	}
}

# subdomains: *.example.com
server {
	disable_symlinks off;
	server_name ~^((?<subdomain>.*)\.)example\.com(\.localhost)?$;

	root /vhosts/example.com/subdomains/${subdomain}/httpdocs;

	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri/ /index.php?$query_string =404;
	}
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/example.com.conf
Nginx advanced configuration that takes regular expression-like.
To make * wildcard work, subdomain will be used for ${subdomain}

Restart Nginx to apply new configurations and visit to see:
- example.com.localhost
- antique.example.com.localhost
- beauty.example.com.localhost

Configuring NGINX for PHP Projects

/var/docker/docker-xnmp-vhosts/
  data/
    nginx/
      enabled/
        php5.conf
        php7.conf
        php8.conf
      snippets/
        php-5.6.23-fpm.conf
        php-7.0.8-fpm.conf
        php-8.0.0-fpm.conf
    vhosts/
      php5/httpdocs/index.php
      php7/httpdocs/index.php
      php8/httpdocs/index.php
File Structure

PHP 5 & 7 & 8

Create simple phpinfo files in the vhosts directory

<?php phpinfo()
file: index.php
file: /var/docker/docker-xnmp-vhosts/data/vhosts/php5/httpdocs/index.php
file: /var/docker/docker-xnmp-vhosts/data/vhosts/php7/httpdocs/index.php
file: /var/docker/docker-xnmp-vhosts/data/vhosts/php8/httpdocs/index.php

Create reusable snippets

#location ~ \.php(/|$) {
	try_files      $uri = 404;
	fastcgi_index  index.php;
	fastcgi_split_path_info ^(.+\.php)(/.*)$;
	include fastcgi_params;
	fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
	fastcgi_param HTTPS on;
	fastcgi_buffers 16 16k;
	fastcgi_buffer_size 32k;
#}
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/snippet-php-fastcgi.conf
location ~ \.php(/|$) {
	include /nginx/snippets/snippet-php-fastcgi.conf;
	fastcgi_pass 5-6-23-fpm:9000;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/php-5.6.23-fpm.conf
location ~ \.php(/|$) {
	include /nginx/snippets/snippet-php-fastcgi.conf;
	fastcgi_pass 7-0-8-fpm:9000;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/php-7.0.8-fpm.conf
location ~ \.php(/|$) {
	include /nginx/snippets/snippet-php-fastcgi.conf;
	fastcgi_pass 8-0-0-fpm:9000;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/php8.0.0-fpm.conf

Apply reusable snippets for domains

server {
	disable_symlinks off;
	server_name ~^php5(\.localhost)?$;
	root /vhosts/php5/httpdocs;
	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	include /nginx/snippets/php-5.6.23-fpm.conf;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/php5.conf
server {
	disable_symlinks off;
	server_name ~^php7(\.localhost)?$;
	root /vhosts/php7/httpdocs;
	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	include /nginx/snippets/php-7.0.8-fpm.conf;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/php7.conf
server {
	disable_symlinks off;
	server_name ~^php8(\.localhost)?$;
	root /vhosts/php8/httpdocs;
	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	include /nginx/snippets/php-8.0.0-fpm.conf;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/php8.conf

Restart Nginx to apply new configurations and visit to see:
- php5.localhost
- php7.localhost
- php8.localhost

Revive legacy projects with PHP extensions

When you found out that your old website uses Mcrypt, your page shows PHP errors and simple docker container isn’t enough that would force you to install some extensions.

/var/docker/docker-xnmp-vhosts/
  docker-compose.yml
  build/7-0-8-fpm-ext/
    Dockerfile
  data/
    nginx/
      enabled/php7-ext.conf
      snippets/php-7.0.8-fpm-ext.conf
    vhosts/
      php7-ext/httpdocs/index.php
File Structure

Add legacy project in Virtual Host directory

<?php phpinfo()
file: /var/docker/docker-xnmp-vhosts/data/vhosts/php7-ext/httpdocs/index.php

Create Nginx snippet and domain config

location ~ \.php(/|$) {
	include /nginx/snippets/snippet-php-fastcgi.conf;
	fastcgi_pass 7-0-8-fpm-ext:9000;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/php-7.0.8-fpm-ext.conf
server {
	disable_symlinks off;
	server_name ~^php7-ext(\.localhost)?$;
	root /vhosts/php7-ext/httpdocs;
	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-7.0.8-fpm-ext.conf;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/php7-ext.conf

Create custom Dockerfile with php extensions

Luckily you can create your own Dockerfile and use that in your  docker-compose.yml.

PHP 7.0.8 FPM with Extensions

FROM php:7.0.8-fpm-alpine

RUN apk add --no-cache --update \
        libmcrypt \
        libmcrypt-dev \
    && docker-php-ext-install \
        mysqli \
        opcache \
        intl \
        sockets \
        mcrypt \
    && rm -rf /tmp/* /var/cache/apk/* \
    && echo "=============================================" \
    && php -m
file: /var/docker/docker-xnmp-vhosts/build/7-0-8-fpm-ext/Dockerfile
php:7.0.8-fpm-alpine is docker image it build from. You can choose whichever version you wish, but the installation script may change, for example mcrypt on version 7.1.x.

PHP 8.0.0 FPM with Extensions

FROM php:8.0.0-fpm-alpine

######## [PHP Modules] Default ########
#### Core
#### ctype
#### curl
#### date
#### dom
#### fileinfo
#### filter
#### ftp
#### hash
#### iconv
#### json
#### libxml
#### mbstring
#### mysqlnd
#### openssl
#### pcre
#### PDO
#### pdo_sqlite
#### Phar
#### posix
#### readline
#### Reflection
#### session
#### SimpleXML
#### sodium
#### SPL
#### sqlite3
#### standard
#### tokenizer
#### xml
#### xmlreader
#### xmlwriter
#### zlib

######## Composer.phar ########
RUN curl -s https://getcomposer.org/installer | php \
  # move composer into a bin directory you control:
  && mv composer.phar /usr/local/bin/composer \
  # double check composer works
  && composer about

RUN php -m && echo "============================================="

######## Dependencies ########
#### bzip2-dev: bz2
#### enchant2-dev: enchant
#### gd: libpng-dev
#### gmp: gmp-dev
#### imap: imap-dev
#### intl: icu-dev
#### ldap: openldap-dev
#### pdo_dblib: freetds-dev
#### pdo_pgsql: postgresql-dev
#### pgsql: postgresql-dev
#### pspell: aspell-dev
#### snmp: net-snmp-dev
#### soap: libxml2-dev
#### tidy: tidyhtml-dev
#### xsl: libxslt-dev
#### zip: libzip-dev

RUN apk add --no-cache --update \
  bzip2-dev \
  enchant2-dev \
  libpng-dev \
  gmp-dev \
  imap-dev \
  icu-dev \
  openldap-dev \
  freetds-dev \
  postgresql-dev \
  aspell-dev \
  net-snmp-dev \
  libxml2-dev \
  tidyhtml-dev  \
  libxslt-dev \
  libzip-dev

RUN docker-php-ext-install \
  bcmath \
  bz2 \
  calendar \
  dba \
  enchant \
  exif \
  ffi \
  gd \
  gettext \
  gmp \
  imap \
  intl \
  ldap \
  mysqli \
  opcache \
  pcntl \
  pdo_dblib \
  pdo_mysql \
  pdo_pgsql \
  pgsql \
  pspell \
  shmop \
  snmp \
  soap \
  sockets \
  sysvmsg \
  sysvsem \
  sysvshm \
  tidy \
  xsl \
  zend_test \
  zip

######## PHP MODULES not working yet ########
#### oci8
#### odbc
#### pdo_firebird
#### pdo_oci
#### pdo_odbc

RUN rm -rf /tmp/* /var/cache/apk/* \
  && echo "=============================================" \
  && php -m
file: /var/docker/docker-xnmp-vhosts/build/8-0-0-fpm-ext/Dockerfile
Already installed PHP modules from php:8.0.0-fpm-alpine.
[PHP Modules]
Core
ctype
curl
date
dom
fileinfo
filter
ftp
hash
iconv
json
libxml
mbstring
mysqlnd
openssl
pcre
PDO
pdo_sqlite
Phar
posix
readline
Reflection
session
SimpleXML
sodium
SPL
sqlite3
standard
tokenizer
xml
xmlreader
xmlwriter
zlib
Already included from the default image: 8.0.0-fpm-alpine
PHP MODULES not yet installed:  oci8   odbc   pdo_firebird   pdo_oci   pdo_odbc

Apply PHP with extension Dockerfile in the docker-compose.yml.

Add new service: 7-0-8-fpm-ext

services:
  7-0-8-fpm-ext:
    build: build/7-0-8-fpm-ext
    restart: always
    volumes_from:
      - data
New service: 7-0-8-fpm-ext, snippet: docker-compose.yml
Snippet

Add new service: 8-0-0-fpm-ext

services:
  8-0-0-fpm-ext:
    build: build/8-0-0-fpm-ext
    restart: always
    volumes_from:
      - data
New service: 8-0-0-fpm-ext, snippet: docker-compose.yml
Snippet

Link Nginx with the new service

services:
  nginx:
    links:
      - 7-0-8-fpm-ext
      - 8-0-0-fpm-ext
Linked service 7-0-8-fpm-ext and 8-0-0-fpm-ext, snippet: docker-compose.yml
Snippet

Full configuration

version: '2'

services:
  nginx:
    image: nginx:alpine
    restart: always
    links:
      - 5-6-23-fpm
      - 7-0-8-fpm
      - 7-0-8-fpm-ext
      - 8-0-0-fpm
      - 8-0-0-fpm-ext
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./data/nginx/enabled:/etc/nginx/conf.d
      - ./data/nginx/snippets:/nginx/snippets
    volumes_from:
      - data

  5-6-23-fpm:
    image: php:5.6.23-fpm-alpine
    restart: always
    volumes_from:
      - data

  7-0-8-fpm:
    image: php:7.0.8-fpm-alpine
    restart: always
    volumes_from:
      - data

  7-0-8-fpm-ext:
    build: build/7-0-8-fpm-ext
    restart: always
    volumes_from:
      - data

  8-0-0-fpm:
    image: php:8.0.0-fpm-alpine
    restart: always
    volumes_from:
      - data

  8-0-0-fpm-ext:
    build: build/8-0-0-fpm-ext
    restart: always
    volumes_from:
      - data

  db:
    image: mariadb #v10.5.8
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root
    volumes:
      - ./data/db:/var/lib/mysql

  dbadmin:
    image: phpmyadmin/phpmyadmin
    restart: always
    environment:
      PMA_HOST: db
      PMA_USER: root # Remove line for production
      PMA_PASSWORD: root # Remove line for production
    depends_on:
      - db

  data:
    image: alpine:latest
    command: echo "--- Docker data volume READY."
    volumes:
      - ./data/vhosts:/vhosts
      - ./data/tmp:/tmp
file: /var/docker/docker-xnmp-vhosts/docker-compose.yml

This time we only need to full restart the docker containers to take effect.

If you have a running terminal and you used: docker-compose up you can press:
Ctrl+C to close program

# Check running containers
docker-compose ps

# Kill containers when you use: docker-compose up -d
docker-compose down

# Start containers as background
docker-compose up -d
Run commands from: /var/docker/docker-xnmp-vhosts/
Command options

A symbolic link for example to the latest version php configuration file.

/var/docker/docker-xnmp-vhosts/data/
  nginx/
    snippets/php-fpm-default.conf
File Structure

For example: ln -nsf <source> <target>

# Create php-fpm-default.conf symlink for later use
ln -nsf php-8.0.0-fpm-ext.conf php-fpm-default.conf
Run commands from: /var/docker/docker-xnmp-vhosts/nginx/snippets/

Manage MySQL Database with dockerized Phpmyadmin

Since we already have a mysql-database and phpmyadmin-webapp in our docker-compose.yml. We only need to config Nginx and point to it.

/var/docker/docker-xnmp-vhosts/
  data/
    nginx/
      enabled/dbadmin.conf
      snippets/snippet-server-location-upstream.conf
File Structure

Nginx configuration for Phpmyadmin

# server {
#     server_name ~^dbadmin(\.localhost)?$;
#     resolver 127.0.0.11 valid=30s;
#     set $upstream http://dbadmin:80;
    location / {
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-NginX-Proxy true;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_pass $upstream;
    }
# }
file: /var/docker/docker-xnmp-vhosts/nginx/snippets/snippet-server-location-upstream.conf
server {
    server_name ~^dbadmin(\.localhost)?$;
    resolver 127.0.0.11 valid=30s;
    set $upstream http://dbadmin:80;
    include /nginx/snippets/snippet-server-location-upstream.conf;
}
file: /var/docker/docker-xnmp-vhosts/nginx/enabled/dbadmin.conf

Restart Nginx to apply new configurations and visit to see:
- dbadmin.localhost

host: db, username: root, password: root
To change database root password edit docker-compose.yml and look for MYSQL_ROOT_PASSWORD

Configure Self-signed Certificates SSL for development

/var/docker/docker-xnmp-vhosts/
  data/
    nginx/
      bin/
        createDomainDirectory.sh
        createLocalhost.sh
        createNginxSslConfigFileExample.sh
        createRootCA.sh
        dns.txt.example
        recreateNginxSslConfigFileExample.sh
        README.md
      enabled/ssl.conf
      snippets/
        snippet-ssl.conf
        ssl-defaultserver.conf
        ssl-domain.conf
    vhosts/
      ssl/
        httpdocs/index.php
        subdomains/
          test/httpdocs/index.php
File Structure

Post-install SSL for Debian/Ubuntu

When you run command (when you followed and finish this SSL article)  
curl https://ssl.localhost would work perfectly fine when in you install certificates on your system, but visiting that URL in Chrome will not. You’ll get security error page. On Mac wouldn't have that problem because those browsers uses the system Trust Store.

You can tell applications (such as Chrome / Firefox) that use NSS for its certificate management to use the system Trust Store.

sudo apt-get update && sudo apt-get install -y p11-kit libnss3
find / -type f -name "libnssckbi.so" 2>/dev/null | while read line; do
    sudo mv $line ${line}.bak
    sudo ln -s /usr/lib/x86_64-linux-gnu/pkcs11/p11-kit-trust.so $line
done
There seem to be more versions of libnssckbi.so out there than just in libnss3. The following is a script to find them all, back them up, and replace them with links to p11-kit
Found this script from Superuser
Guide to add self-generated root certificate authorities for 8 operating systems and browsers

Create SSL simple pages for vhosts

<?php phpinfo()
file: /var/docker/docker-xnmp-vhosts/data/vhosts/ssl/httpdocs/index.php
file: /var/docker/docker-xnmp-vhosts/data/vhosts/ssl/httpdocs/index.php
file: /var/docker/docker-xnmp-vhosts/data/vhosts/ssl/subdomains/test/httpdocs/index.php

Create SSL Snippets for Nginx

# ssl                 on;
ssl_certificate     /nginx/certificates/localhost/localhost.crt;
ssl_certificate_key /nginx/certificates/localhost/localhost.key;
ssl_session_timeout  5m;
ssl_protocols TLSv1.1 TLSv1.2;
ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA';
ssl_prefer_server_ciphers on;
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/snippet-ssl.conf
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
include /nginx/snippets/snippet-ssl.conf;
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/ssl-defaultserver.conf
listen 443 ssl;
listen [::]:443 ssl;
include /nginx/snippets/snippet-ssl.conf;
file: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/ssl-domain.conf

Optional: ssl.conf

This Nginx config file will be generated by the bin script
server {
	server_name ~^ssl(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
    include /nginx/snippets/ssl-domain.conf;
	server_name ~^ssl(\.localhost)?$;

	index index.html index.php;
    root /vhosts/ssl/httpdocs;
	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-fpm-default.conf;
}

# SUBDOMAINS
server {
	server_name ~^((?<subdomain>.*)\.)ssl(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
	include /nginx/snippets/ssl-domain.conf;
	server_name ~^((?<subdomain>.*)\.)ssl(\.localhost)?$;

	index index.html index.php;
	root /vhosts/ssl/subdomains/${subdomain}/httpdocs;
	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-fpm-default.conf;
}
file: /var/docker/docker-xnmp-vhosts/data/nginx/enabled/ssl.conf

Scripts to create SSL Certificates for localhost development

Skip this part if you want to download script files and run single command.

dns.txt

localhost
*.localhost
*.example.com.localhost
example.com.localhost
php5.localhost
*.php5.localhost
php7.localhost
*.php7.localhost
php7-ext.localhost
*.php7-ext.localhost
php8.localhost
*.php8.localhost
php8-ext.localhost
*.php8-ext.localhost
ssl.localhost
*.ssl.localhost
file: /var/docker/docker-xnmp-vhosts/data/nginx/bin/dns.txt

createDomainDirectory.sh

#!/bin/bash
CERT_DIR=../certificates

if [[ $# -eq 0 ]] ; then
    echo -e 'Error Script: Need argument\n\n\tEXAMPLE: ./createDomainDirectory.sh localhost'
    exit 0
fi


OUTPUT=`cat <<EOF
authorityKeyIdentifier = keyid,issuer
basicConstraints = CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
IP.1 = 10.10.10.20
IP.2 = 127.0.0.1
EOF
`
# OUTPUT: EOF

# Import dns.txt file if exist, else use dns.txt.example and make newline as a array and sort
DNSTXT=dns.txt
[ ! -f "$DNSTXT" ] && DNSTXT=dns.txt.example
IFS=$'\r\n' GLOBIGNORE='*' command eval 'DOMAINS=($(sort <"$DNSTXT"))'; unset IFS

for DNS in "${DOMAINS[@]}"
{
    (( COUNT++ ))
	OUTPUT="${OUTPUT}\nDNS.$COUNT = $DNS"
}
echo -e "$OUTPUT"
echo -e "$OUTPUT" > domains.ext

# Create Dir
mkdir -p $CERT_DIR/$1
# Generate Certificates and Keys for Domain
openssl req -new -nodes -newkey rsa:2048 -keyout $CERT_DIR/$1/$1.key -out $CERT_DIR/$1/$1.csr -subj "/CN=localhost"
openssl x509 -req -sha256 -days 1024 -in $CERT_DIR/$1/$1.csr -CA $CERT_DIR/RootCA.pem -CAkey $CERT_DIR/RootCA.key -CAcreateserial -extfile domains.ext -out $CERT_DIR/$1/$1.crt
file: /var/docker/docker-xnmp-vhosts/data/nginx/bin/createDomainDirectory.sh

createLocalhost.sh

#!/bin/bash
./createDomainDirectory.sh localhost
file: /var/docker/docker-xnmp-vhosts/data/nginx/bin/createLocalhost.sh

createRootCA.sh

#!/bin/bash
CERT_DIR=../certificates
openssl req -x509 -nodes -new -sha256 -days 1024 -newkey rsa:2048 -keyout $CERT_DIR/RootCA.key -out $CERT_DIR/RootCA.pem -subj "/C=NL/O=XNMP/CN=XNMP-Root-CA"
openssl x509 -outform pem -in $CERT_DIR/RootCA.pem -out $CERT_DIR/RootCA.crt

COMMAND_CP_CA_CERTIFICATES_TO_SYSTEM="cp $CERT_DIR/RootCA.crt /usr/local/share/ca-certificates && update-ca-certificates -f"
if [[ -d /usr/local/share/ca-certificates ]]; then
   [[ $EUID -ne 0 ]] && sudo bash -c "$COMMAND_CP_CA_CERTIFICATES_TO_SYSTEM" || "$COMMAND_CP_CA_CERTIFICATES_TO_SYSTEM"
   echo "#--- RootCA.crt copied to /usr/local/share/ca-certificates and Updated"
fi
file: /var/docker/docker-xnmp-vhosts/data/nginx/bin/createRootCA.sh

createNginxSslConfigFileExample.sh

#!/bin/bash
CERT_DIR=../certificates
ENABLED_DIR=../enabled

[ -f "$ENABLED_DIR/ssl.conf" ] && echo -e "Error Script: $ENABLED_DIR/ssl.conf already exist!" && exit 0
# RootCA
[[ ! -f "$CERT_DIR/RootCA.pem" || ! -f "$CERT_DIR/RootCA.key" ]] && ./createRootCA.sh && echo RootCA CREATED
# localhost
[[ ! -f "$CERT_DIR/localhost/localhost.crt" || ! -f "$CERT_DIR/localhost/localhost.key" ]] && ./createLocalhost.sh localhost && echo localhost.* CREATED

OUTPUT=`cat <<'EOF'
server {
	server_name ~^ssl(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
    include /nginx/snippets/ssl-domain.conf;
	server_name ~^ssl(\.localhost)?$;

	index index.html index.php;
    root /vhosts/ssl/httpdocs;
	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-fpm-default.conf;
}

# SUBDOMAINS
server {
	server_name ~^((?<subdomain>.*)\.)ssl(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
	include /nginx/snippets/ssl-domain.conf;
	server_name ~^((?<subdomain>.*)\.)ssl(\.localhost)?$;

	index index.html index.php;
	root /vhosts/ssl/subdomains/${subdomain}/httpdocs;
	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-fpm-default.conf;
}
EOF
`
# OUTPUT: EOF

echo -e "$OUTPUT" > $ENABLED_DIR/ssl.conf
echo DONE.
file: /var/docker/docker-xnmp-vhosts/data/nginx/bin/createNginxSslConfigFileExample.sh

recreateNginxSslConfigFileExample.sh

#!/bin/bash
CERT_DIR=../certificates
ENABLED_DIR=../enabled

# [ -f "$ENABLED_DIR/ssl.conf" ] && echo -e "Error Script: $ENABLED_DIR/ssl.conf already exist!" && exit 0
# RootCA
./createRootCA.sh && echo RootCA CREATED
# localhost
./createLocalhost.sh localhost && echo "localhost.(crt|key|csr) CREATED"

OUTPUT=`cat <<'EOF'
server {
	server_name ~^ssl(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
    include /nginx/snippets/ssl-domain.conf;
	server_name ~^ssl(\.localhost)?$;

	index index.html index.php;
    root /vhosts/ssl/httpdocs;
	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-fpm-default.conf;
}

# SUBDOMAINS
server {
	server_name ~^((?<subdomain>.*)\.)ssl(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
	include /nginx/snippets/ssl-domain.conf;
	server_name ~^((?<subdomain>.*)\.)ssl(\.localhost)?$;

	index index.html index.php;
	root /vhosts/ssl/subdomains/${subdomain}/httpdocs;
	location / {
		try_files $uri $uri/ /index.php$is_args$args;
	}

	include /nginx/snippets/php-fpm-default.conf;
}
EOF
`
# OUTPUT: EOF

echo -e "$OUTPUT" > $ENABLED_DIR/ssl.conf
echo DONE.
file: /var/docker/docker-xnmp-vhosts/data/nginx/bin/recreateNginxSslConfigFileExample.sh

Download bin.zip

The source inside you’ll find above information

Download script files and extract them to nginx/bin folder, shown above, then run:

./createNginxSslConfigFileExample.sh
Run script from: /var/docker/docker-xnmp-vhosts/data/nginx/bin
Certificates will be generated in this folder: /var/docker/docker-xnmp-vhosts/data/nginx/certificates

Restart Nginx to apply new configurations and visit to see:
- ssl.localhost

How to use the bin files

  • When you want update or create SSL certificates for new (sub)domains you can append newline in dns.txt. For example:
ghost.localhost
*.ghost.localhost
snippet: /var/docker/docker-xnmp-vhosts/data/nginx/bin/dns.txt

Then run to update all your certificates:

./createLocalhost.sh
Run script from: /var/docker/docker-xnmp-vhosts/data/nginx/bin

Restart Nginx to apply new configurations and visit to see:
- ghost.localhost
- blog.ghost.localhost

  • When you want to renew SSL RootCA Certificate, then run:
./createRootCA.sh
./createLocalhost.sh
Run script from: /var/docker/docker-xnmp-vhosts/data/nginx/bin
Install your generated RootCA.pem in your system, browser or device, follow guide from: Install Root Certificates
You might need to restart your computer to take effect

Restart Nginx to apply new configurations.

  • When you want to start over again and regenerate the files, then run:
./recreateNginxSslConfigFileExample.sh
Run script from: /var/docker/docker-xnmp-vhosts/data/nginx/bin

Restart Nginx to apply new configurations.

Configure Letsencrypt for production

/etc/letsencrypt/
  live/domain.ext/
    fullchain.pem
    privkey.pem

/var/docker/docker-xnmp-vhosts/
  data/
    nginx/
      enabled/domain.ext.conf
      snippets/
        listen-ssl.conf
        ssl-domain-ext.conf
    vhosts/
      domain.ext/
        httpdocs/index.php
        subdomains/
          test/httpdocs/index.php
  docker-compose.yml
File Structure
domain.ext can be relplaced with your own domain ex: sylo.space
listen 443 ssl http2;
listen [::]:443 ssl http2;
# ssl_session_timeout  5m;
# ssl_protocols TLSv1.1 TLSv1.2;
# ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kEDH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA';
# ssl_prefer_server_ciphers on;
ssl_protocols           TLSv1.2 TLSv1.3;
ssl_ciphers             HIGH:!aNULL:!MD5;
File: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/listen-ssl.conf
include /nginx/snippets/listen-ssl.conf;
ssl_certificate     /etc/letsencrypt/live/domain.ext/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/domain.ext/privkey.pem;
File: /var/docker/docker-xnmp-vhosts/data/nginx/snippets/ssl-domain-ext.conf
# redirect 80 domain.ext to 443 ssl
server {
	listen 80;
	listen [::]:80;
	server_name ~^domain\.ext(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {  
	include /nginx/snippets/ssl-domain-ext.conf;

	server_name ~^domain\.ext(\.localhost)?$;
	root /vhosts/domain.ext/httpdocs;
	index index.html index.php;
	
	location / {
		try_files $uri $uri/ /index.php?$query_string;
	}

	include /nginx/snippets/php-fpm-latest.conf;
}

# Subdomains
server {
	listen 80;
	server_name ~^((?<subdomain>.*)\.)domain\.ext(\.localhost)?$;
	return 301 https://$host$request_uri;
}

server {
	include /nginx/snippets/ssl-domain-ext.conf;

	server_name ~^((?<subdomain>.*)\.)domain\.ext(\.localhost)?$;
	root /vhosts/domain.ext/subdomains/${subdomain}/httpdocs;
	autoindex on;
	index index.html index.php;

	location / {
		try_files $uri $uri.html $uri/ /index.html /index.php$is_args$args;
		include /nginx/snippets/snippet-location-root-cors.conf;
	}

	include /nginx/snippets/php-fpm-latest.conf;
}
File: data/nginx/enabled/domain.ext.conf
version: '2'

services:
  data:
    image: alpine:latest
    command: /bin/sh
    volumes:
      - ./data/vhosts:/vhosts
      - ./data/tmp:/tmp
      - /etc/letsencrypt:/etc/letsencrypt:ro
Snippet: docker-compose.yml
add line on volumes: - /etc/letsencrypt:/etc/letsencrypt:ro

Optional: Dockerized certbot (Letsencrypt) runner for you live domain certificates

/var/docker/docker-xnmp-vhosts/
  build/letsencrypt/
    config/
      domain.txt.example
      email.txt.example
    build/
      certbot-alpine/
        docker-entrypoint.sh
        Dockerfile
    docker-build.sh
    docker-run.sh
  data/
    nginx/
      enabled/
        domain.ext.conf
        domain2.ext.conf
    vhosts/
      domain.ext/
        httpdocs/index.php
        subdomains/
      	  test/httpdocs/index.php
      domain2.ext/
        httpdocs/index.php
        subdomains/
          test/httpdocs/index.php
      	  sub1/httpdocs/index.php
      	  sub2/httpdocs/index.php
File Structure
This part only focus on the letsencrypt/ . The other parts nginx/ and vhosts/ you need to configure yourself from what you have learned so far or whatever you need. I add this so you could have better reference.

Let’s say you want domain certificates with wildcards for example domain.ext and domain2.ext. It would be tedious work to run certbot every time for each domain you own. Of course you don’t have this problem if you only had just one domain to worry about, even though you can still use this method. So I created a script that checks list of domains and email from text files.

email.txt; where Letsencrypt send you an email when your certificates almost expire)

This script will run like an Interactive Shell, when it needs some of your input.

Before you run this script, have your DNS Records ready to edit or add TXT records.

Setup Dockerfile and bash scripts

FROM alpine:latest

RUN apk add --no-cache --update \
    certbot \
    bash

ENV HOME=/root
WORKDIR $HOME

ADD docker-entrypoint.sh $HOME

ENTRYPOINT ~/docker-entrypoint.sh
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/build/cerbot-alpine/Dockerfile
#!/bin/bash

EMAIL=`cat email.txt`
SERVER=https://acme-v02.api.letsencrypt.org/directory
CERTBOTARGS="certonly --agree-tos -m $EMAIL --manual --manual-public-ip-logging-ok --preferred-challenges dns --server $SERVER"
DNSTXT=domains.txt
IFS=$'\r\n' GLOBIGNORE='*' command eval 'DOMAINS=($(<"$DNSTXT"))'; unset IFS

# cd /root/certbot
for DOMAIN in "${DOMAINS[@]}"
{
    allDNS=($DOMAIN)
    args=()
    for dns in "${allDNS[@]}"; do args+=(-d "$dns"); done

    echo "###: $DOMAIN"
    certbot $CERTBOTARGS --cert-name ${allDNS[0]} "${args[@]}"
    echo ================================

}
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/certbot-alpine/docker-entrypoint.sh
#!/bin/bash
docker build -t harianto/certbot-alpine build/certbot-alpine
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/docker-build.sh
#!/bin/bash

docker run --rm \
-v $PWD/config/email.txt:/root/email.txt:ro \
-v $PWD/config/domains.txt:/root/domains.txt:ro \
-v $PWD/../../data:/etc/letsencrypt \
-it harianto/certbot-alpine
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/docker-run.sh

Config examples

you@domain.ext
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/config/email.txt.example
domain.ext *.domain.ext
domain2.ext *.domain2.ext *.sub1.domain2.ext *.sub2.domain2.ext
File: build/letsencrypt/config/domains.txt.example

Manual: Letsencrypt Certificate Maker

# Letsencrypt Certificate Maker

Little more automation with multiple domains and wildcards

## Build Once

Run

```bash
# Build a docker image: harianto/certbot-alpine
./docker-build.sh
```


## Config Files

In `config` directory you’ll find `.example` files or create:
`email.txt` and `domains.txt`

### email.txt

Create `email.txt`

```txt
you@domain.ext
```
> Put your email address where Letsencrypt can notify you when your ceritifcates almost expires


### domains.txt

Create `domains.txt`

```txt
domain.ext *.domain.ext
domain2.ext *.domain2.ext *.sub1.domain2.ext *.sub2.domain2.ext
```
> Each line need to be unique domain as `domain` DOT `ext` name, and follow subdomains that needs a wildcard `*`. 
> For example: `*.domain.ext`

## Create certificates

Make sure you already build once with `docker-build.sh`. 

Also you put your valid email.txt and domains.txt.

Run

```bash
# run image harianto/certbot-alpine
./docker-run.sh
```
> This will run in Interactive Shell mode while you need to follow and have time to set up your DNS tables
> All letsencrypt magic will be stored in `data` directory

## Notes

Make sure **docker-compose.yml** link correct folders in `nginx:`

```yml
service:
  nginx:
    volumes:
      - ./data/letsencrypt:/etc/letsencrypt:ro
```
> Snippet: `docker-compose.yml`

File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/README.md
In short:
- run once: docker-build.sh
- config email and domains
- run: docker-run.sh

Optional Add-On: certbot-plugin-gandi

Since I’m using Gandi registrar, I manually edit the records with their web system manager. It was tedious when I need to renew certificates for 4 domains, then I made myself a bit more easy using their API with self-made node script. Actually I was planning to learn some python, and going to make a plugin for certbot. Good thing somebody already made it.

Here are modified script for using certbot plugin.

/var/docker/docker-xnmp-vhosts/
  build/
    letsencrypt/
      config/
        APIKEY
        APIKEY.example
        gandi.ini
        gandi.ini.example
      build/
        certbot-alpine/
          docker-entrypoint.sh
          Dockerfile
      docker-run.sh
File Structure

#!/bin/bash

EMAIL=`cat email.txt`
SERVER=https://acme-v02.api.letsencrypt.org/directory
CERTBOTARGS="certonly --agree-tos -m $EMAIL --manual-public-ip-logging-ok --preferred-challenges dns --server $SERVER -a certbot-plugin-gandi:dns --certbot-plugin-gandi:dns-credentials gandi.ini"
DNSTXT=domains.txt
IFS=$'\r\n' GLOBIGNORE='*' command eval 'DOMAINS=($(<"$DNSTXT"))'; unset IFS

# cd /root/certbot
for DOMAIN in "${DOMAINS[@]}"
{
    allDNS=($DOMAIN)
    args=()
    for dns in "${allDNS[@]}"; do args+=(-d "$dns"); done

    echo "###: $DOMAIN"
    certbot $CERTBOTARGS --cert-name ${allDNS[0]} "${args[@]}"
    echo ================================

}
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/build/certbot-alpine/docker-entrypoint.sh
gandiAPIkeyGANDIapiKeyXX
File Example: /var/docker/docker-xnmp-vhosts/build/letsencrypt/config/APIKEY.example
create APIKEY file and get API key from Gandi
# live dns v5 api key
certbot_plugin_gandi:dns_api_key=APIKEY

# optional organization id, remove it if not used
certbot_plugin_gandi:dns_sharing_id=SHARINGID
File Example: /var/docker/docker-xnmp-vhosts/build/letsencrypt/config/gandi.ini.example
create gandi.ini file and change APIKEY to $APIKEY (Yes, dollar sign) as an environmental variable.
FROM alpine:latest

RUN apk add --no-cache --update \
    certbot \
    python3 \
    py3-pip \
    bash \
    && pip3 install --upgrade pip \
    && pip install certbot-plugin-gandi

ENV HOME=/root
WORKDIR $HOME

ADD sh/renew-crt.sh $HOME

ENTRYPOINT ~/renew-crt.sh
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/build/certbot-alpine/Dockerfile
#!/bin/bash

docker run --rm \
-e APIKEY=`cat config/APIKEY` \
-v $PWD/config/email.txt:/root/email.txt:ro \
-v $PWD/config/domains.txt:/root/domains.txt:ro \
-v $PWD/data:/etc/letsencrypt \
-it harianto/certbot-alpine
File: /var/docker/docker-xnmp-vhosts/build/letsencrypt/docker-run.sh

Now I can just run ./docker-run.sh and automate the process. Done!

Share network

You can share network, by append this snippet below.

# create network: docker network create xnmp-network
networks:
  default:
    external:
      name: xnmp-network
snippet: /var/docker/docker-xnmp-vhosts/docker-compose.yml

Then create a network xnmp-network

# Run once
docker network create xnmp-network
You can with other docker-compose.yml together in shared network.

Things I connect trough this shared network

Combined Docker Compose(s) through Docker Network

Sources

GitHub:  Cross-Platform (X), Nginx (N), MariaDB (M), PHP (P)