├── .gitignore ├── README.md ├── app1 └── docker-compose.yml ├── app2 └── docker-compose.yml ├── down.sh ├── router ├── docker-compose.yml └── nginx.tmpl ├── test.sh └── up.sh /.gitignore: -------------------------------------------------------------------------------- 1 | router/data 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # *Multi-App* Web Proxy using Docker-Compose, NGINX, and Let's Encrypt 2 | Here's a fairly straightforward solution for how to setup multiple docker-compose applications that share the same NGINX proxy. 3 | 4 | ## Example 5 | This is what we want to do. 2 docker-compose apps running on the same server, that share the same NGINX proxy. 6 | 7 | ``` 8 | Server: 9 | |-------------------------------------------| 10 | | | 11 | | |--- App 1 (app1.example.com) | 12 | Client --- NGINX ---| | 13 | | |--- App 2 (app2.example.com) | 14 | | | 15 | |-------------------------------------------| 16 | ``` 17 | 18 | ## Problem 19 | You can't include an NGINX proxy in each application becaue then you would have multiple proxies trying to listen on port 80/443. You can only have one. 20 | 21 | Unfortunately, docker-compose doesn't have any built in support for "singleton" services that are shared across projects. I.e. there's no way to say: "Only create this service if it doesn't already exist." 22 | 23 | ## Solution 24 | My solution is to split up the above example into 3 projects and share the same network across all projects. 25 | 26 | ### router 27 | This is the web proxy (router). It sets up the nginx container, the jwilder nginx-gen container, and a letsencrypt companion. By default it will also create a network called "router_default" (see caveat below). 28 | 29 | ### apps 30 | The only thing you have to change is to use the "router_default" network. 31 | 32 | ``` 33 | networks: 34 | default: 35 | external: 36 | name: router_default 37 | ``` 38 | 39 | ## Usage 40 | To start everything up, just run `docker-compose up` for each project (starting with `router`). I have a helper script that does this (`up.sh`) 41 | 42 | ## Caveat 43 | The network name is generated based on the router's project name (which is by default the directory name). If you rename the project then you have to rename the app networks. 44 | 45 | An alternative solution would be to explicitly create your own network. For example: 46 | 47 | ``` 48 | docker network create my_router 49 | ``` 50 | 51 | Then, add the following to the **router project** (and to your apps): 52 | 53 | ``` 54 | networks: 55 | default: 56 | external: 57 | name: my_router 58 | ``` 59 | 60 | ## References 61 | 62 | Docker NGINX Proxy: 63 | https://github.com/jwilder/nginx-proxy 64 | 65 | Let's Encrypt NGINX Proxy Companion: 66 | https://github.com/JrCs/docker-letsencrypt-nginx-proxy-companion 67 | 68 | Docker-Compose Example: 69 | https://github.com/evertramos/docker-compose-letsencrypt-nginx-proxy-companion 70 | -------------------------------------------------------------------------------- /app1/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | whoami: 5 | image: jwilder/whoami 6 | environment: 7 | - VIRTUAL_HOST=app1.example.com 8 | 9 | networks: 10 | default: 11 | external: 12 | name: router_default 13 | -------------------------------------------------------------------------------- /app2/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | whoami: 5 | image: jwilder/whoami 6 | environment: 7 | - VIRTUAL_HOST=app2.example.com 8 | 9 | networks: 10 | default: 11 | external: 12 | name: router_default 13 | -------------------------------------------------------------------------------- /down.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | (cd router && docker-compose down) 4 | 5 | (cd app1 && docker-compose down) 6 | 7 | (cd app2 && docker-compose down) 8 | -------------------------------------------------------------------------------- /router/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | nginx: 6 | image: nginx 7 | container_name: nginx 8 | labels: 9 | com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true" 10 | restart: always 11 | ports: 12 | - "80:80" 13 | - "443:443" 14 | volumes: 15 | - ./data/router/conf.d:/etc/nginx/conf.d 16 | - ./data/router/vhost.d:/etc/nginx/vhost.d 17 | - ./data/router/html:/usr/share/nginx/html 18 | - ./data/router/certs:/etc/nginx/certs:ro 19 | 20 | nginx-gen: 21 | image: jwilder/docker-gen 22 | container_name: nginx-gen 23 | command: -notify-sighup nginx -watch -wait 5s:30s /etc/docker-gen/templates/nginx.tmpl /etc/nginx/conf.d/default.conf 24 | restart: always 25 | volumes: 26 | - ./data/router/conf.d:/etc/nginx/conf.d 27 | - ./data/router/vhost.d:/etc/nginx/vhost.d 28 | - ./data/router/html:/usr/share/nginx/html 29 | - ./data/router/certs:/etc/nginx/certs:ro 30 | - /var/run/docker.sock:/tmp/docker.sock:ro 31 | - ./nginx.tmpl:/etc/docker-gen/templates/nginx.tmpl:ro 32 | 33 | nginx-letsencrypt: 34 | image: jrcs/letsencrypt-nginx-proxy-companion 35 | container_name: nginx-letsencrypt 36 | restart: always 37 | volumes: 38 | - ./data/router/conf.d:/etc/nginx/conf.d 39 | - ./data/router/vhost.d:/etc/nginx/vhost.d 40 | - ./data/router/html:/usr/share/nginx/html 41 | - ./data/router/certs:/etc/nginx/certs:rw 42 | - /var/run/docker.sock:/var/run/docker.sock:ro 43 | environment: 44 | NGINX_DOCKER_GEN_CONTAINER: "nginx-gen" 45 | NGINX_PROXY_CONTAINER: "nginx" 46 | -------------------------------------------------------------------------------- /router/nginx.tmpl: -------------------------------------------------------------------------------- 1 | {{ $CurrentContainer := where $ "ID" .Docker.CurrentContainerID | first }} 2 | 3 | {{ define "upstream" }} 4 | {{ if .Address }} 5 | {{/* If we got the containers from swarm and this container's port is published to host, use host IP:PORT */}} 6 | {{ if and .Container.Node.ID .Address.HostPort }} 7 | # {{ .Container.Node.Name }}/{{ .Container.Name }} 8 | server {{ .Container.Node.Address.IP }}:{{ .Address.HostPort }}; 9 | {{/* If there is no swarm node or the port is not published on host, use container's IP:PORT */}} 10 | {{ else if .Network }} 11 | # {{ .Container.Name }} 12 | server {{ .Network.IP }}:{{ .Address.Port }}; 13 | {{ end }} 14 | {{ else if .Network }} 15 | # {{ .Container.Name }} 16 | server {{ .Network.IP }} down; 17 | {{ end }} 18 | {{ end }} 19 | 20 | # If we receive X-Forwarded-Proto, pass it through; otherwise, pass along the 21 | # scheme used to connect to this server 22 | map $http_x_forwarded_proto $proxy_x_forwarded_proto { 23 | default $http_x_forwarded_proto; 24 | '' $scheme; 25 | } 26 | 27 | # If we receive X-Forwarded-Port, pass it through; otherwise, pass along the 28 | # server port the client connected to 29 | map $http_x_forwarded_port $proxy_x_forwarded_port { 30 | default $http_x_forwarded_port; 31 | '' $server_port; 32 | } 33 | 34 | # If we receive Upgrade, set Connection to "upgrade"; otherwise, delete any 35 | # Connection header that may have been passed to this server 36 | map $http_upgrade $proxy_connection { 37 | default upgrade; 38 | '' close; 39 | } 40 | 41 | # Apply fix for very long server names 42 | server_names_hash_bucket_size 128; 43 | 44 | # Default dhparam 45 | {{ if (exists "/etc/nginx/dhparam/dhparam.pem") }} 46 | ssl_dhparam /etc/nginx/dhparam/dhparam.pem; 47 | {{ end }} 48 | 49 | # Set appropriate X-Forwarded-Ssl header 50 | map $scheme $proxy_x_forwarded_ssl { 51 | default off; 52 | https on; 53 | } 54 | 55 | gzip_types text/plain text/css application/javascript application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript; 56 | 57 | log_format vhost '$host $remote_addr - $remote_user [$time_local] ' 58 | '"$request" $status $body_bytes_sent ' 59 | '"$http_referer" "$http_user_agent"'; 60 | 61 | access_log off; 62 | 63 | {{ if $.Env.RESOLVERS }} 64 | resolver {{ $.Env.RESOLVERS }}; 65 | {{ end }} 66 | 67 | {{ if (exists "/etc/nginx/proxy.conf") }} 68 | include /etc/nginx/proxy.conf; 69 | {{ else }} 70 | # HTTP 1.1 support 71 | proxy_http_version 1.1; 72 | proxy_buffering off; 73 | proxy_set_header Host $http_host; 74 | proxy_set_header Upgrade $http_upgrade; 75 | proxy_set_header Connection $proxy_connection; 76 | proxy_set_header X-Real-IP $remote_addr; 77 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 78 | proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; 79 | proxy_set_header X-Forwarded-Ssl $proxy_x_forwarded_ssl; 80 | proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port; 81 | 82 | # Mitigate httpoxy attack (see README for details) 83 | proxy_set_header Proxy ""; 84 | {{ end }} 85 | 86 | {{ $enable_ipv6 := eq (or ($.Env.ENABLE_IPV6) "") "true" }} 87 | server { 88 | server_name _; # This is just an invalid value which will never trigger on a real hostname. 89 | listen 80; 90 | {{ if $enable_ipv6 }} 91 | listen [::]:80; 92 | {{ end }} 93 | access_log /var/log/nginx/access.log vhost; 94 | return 503; 95 | } 96 | 97 | {{ if (and (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} 98 | server { 99 | server_name _; # This is just an invalid value which will never trigger on a real hostname. 100 | listen 443 ssl http2; 101 | {{ if $enable_ipv6 }} 102 | listen [::]:443 ssl http2; 103 | {{ end }} 104 | access_log /var/log/nginx/access.log vhost; 105 | return 503; 106 | 107 | ssl_session_tickets off; 108 | ssl_certificate /etc/nginx/certs/default.crt; 109 | ssl_certificate_key /etc/nginx/certs/default.key; 110 | } 111 | {{ end }} 112 | 113 | {{ range $host, $containers := groupByMulti $ "Env.VIRTUAL_HOST" "," }} 114 | 115 | {{ $host := trim $host }} 116 | {{ $is_regexp := hasPrefix "~" $host }} 117 | {{ $upstream_name := when $is_regexp (sha1 $host) $host }} 118 | 119 | # {{ $host }} 120 | upstream {{ $upstream_name }} { 121 | 122 | {{ range $container := $containers }} 123 | {{ $addrLen := len $container.Addresses }} 124 | 125 | {{ range $knownNetwork := $CurrentContainer.Networks }} 126 | {{ range $containerNetwork := $container.Networks }} 127 | {{ if (and (ne $containerNetwork.Name "ingress") (or (eq $knownNetwork.Name $containerNetwork.Name) (eq $knownNetwork.Name "host"))) }} 128 | ## Can be connect with "{{ $containerNetwork.Name }}" network 129 | 130 | {{/* If only 1 port exposed, use that */}} 131 | {{ if eq $addrLen 1 }} 132 | {{ $address := index $container.Addresses 0 }} 133 | {{ template "upstream" (dict "Container" $container "Address" $address "Network" $containerNetwork) }} 134 | {{/* If more than one port exposed, use the one matching VIRTUAL_PORT env var, falling back to standard web port 80 */}} 135 | {{ else }} 136 | {{ $port := coalesce $container.Env.VIRTUAL_PORT "80" }} 137 | {{ $address := where $container.Addresses "Port" $port | first }} 138 | {{ template "upstream" (dict "Container" $container "Address" $address "Network" $containerNetwork) }} 139 | {{ end }} 140 | {{ end }} 141 | {{ end }} 142 | {{ end }} 143 | {{ end }} 144 | } 145 | 146 | {{ $default_host := or ($.Env.DEFAULT_HOST) "" }} 147 | {{ $default_server := index (dict $host "" $default_host "default_server") $host }} 148 | 149 | {{/* Get the VIRTUAL_PROTO defined by containers w/ the same vhost, falling back to "http" */}} 150 | {{ $proto := trim (or (first (groupByKeys $containers "Env.VIRTUAL_PROTO")) "http") }} 151 | 152 | {{/* Get the NETWORK_ACCESS defined by containers w/ the same vhost, falling back to "external" */}} 153 | {{ $network_tag := or (first (groupByKeys $containers "Env.NETWORK_ACCESS")) "external" }} 154 | 155 | {{/* Get the HTTPS_METHOD defined by containers w/ the same vhost, falling back to "redirect" */}} 156 | {{ $https_method := or (first (groupByKeys $containers "Env.HTTPS_METHOD")) "redirect" }} 157 | 158 | {{/* Get the HSTS defined by containers w/ the same vhost, falling back to "max-age=31536000" */}} 159 | {{ $hsts := or (first (groupByKeys $containers "Env.HSTS")) "max-age=31536000" }} 160 | 161 | {{/* Get the VIRTUAL_ROOT By containers w/ use fastcgi root */}} 162 | {{ $vhost_root := or (first (groupByKeys $containers "Env.VIRTUAL_ROOT")) "/var/www/public" }} 163 | 164 | 165 | {{/* Get the first cert name defined by containers w/ the same vhost */}} 166 | {{ $certName := (first (groupByKeys $containers "Env.CERT_NAME")) }} 167 | 168 | {{/* Get the best matching cert by name for the vhost. */}} 169 | {{ $vhostCert := (closest (dir "/etc/nginx/certs") (printf "%s.crt" $host))}} 170 | 171 | {{/* vhostCert is actually a filename so remove any suffixes since they are added later */}} 172 | {{ $vhostCert := trimSuffix ".crt" $vhostCert }} 173 | {{ $vhostCert := trimSuffix ".key" $vhostCert }} 174 | 175 | {{/* Use the cert specified on the container or fallback to the best vhost match */}} 176 | {{ $cert := (coalesce $certName $vhostCert) }} 177 | 178 | {{ $is_https := (and (ne $https_method "nohttps") (ne $cert "") (exists (printf "/etc/nginx/certs/%s.crt" $cert)) (exists (printf "/etc/nginx/certs/%s.key" $cert))) }} 179 | 180 | {{ if $is_https }} 181 | 182 | {{ if eq $https_method "redirect" }} 183 | server { 184 | server_name {{ $host }}; 185 | listen 80 {{ $default_server }}; 186 | {{ if $enable_ipv6 }} 187 | listen [::]:80 {{ $default_server }}; 188 | {{ end }} 189 | access_log /var/log/nginx/access.log vhost; 190 | return 301 https://$host$request_uri; 191 | } 192 | {{ end }} 193 | 194 | server { 195 | server_name {{ $host }}; 196 | listen 443 ssl http2 {{ $default_server }}; 197 | {{ if $enable_ipv6 }} 198 | listen [::]:443 ssl http2 {{ $default_server }}; 199 | {{ end }} 200 | access_log /var/log/nginx/access.log vhost; 201 | 202 | {{ if eq $network_tag "internal" }} 203 | # Only allow traffic from internal clients 204 | include /etc/nginx/network_internal.conf; 205 | {{ end }} 206 | 207 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2; 208 | ssl_ciphers 'ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:!DSS'; 209 | 210 | ssl_prefer_server_ciphers on; 211 | ssl_session_timeout 5m; 212 | ssl_session_cache shared:SSL:50m; 213 | ssl_session_tickets off; 214 | 215 | ssl_certificate /etc/nginx/certs/{{ (printf "%s.crt" $cert) }}; 216 | ssl_certificate_key /etc/nginx/certs/{{ (printf "%s.key" $cert) }}; 217 | 218 | {{ if (exists (printf "/etc/nginx/certs/%s.dhparam.pem" $cert)) }} 219 | ssl_dhparam {{ printf "/etc/nginx/certs/%s.dhparam.pem" $cert }}; 220 | {{ end }} 221 | 222 | {{ if (exists (printf "/etc/nginx/certs/%s.chain.crt" $cert)) }} 223 | ssl_stapling on; 224 | ssl_stapling_verify on; 225 | ssl_trusted_certificate {{ printf "/etc/nginx/certs/%s.chain.crt" $cert }}; 226 | {{ end }} 227 | 228 | {{ if (and (ne $https_method "noredirect") (ne $hsts "off")) }} 229 | add_header Strict-Transport-Security "{{ trim $hsts }}"; 230 | {{ end }} 231 | 232 | {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} 233 | include {{ printf "/etc/nginx/vhost.d/%s" $host }}; 234 | {{ else if (exists "/etc/nginx/vhost.d/default") }} 235 | include /etc/nginx/vhost.d/default; 236 | {{ end }} 237 | 238 | location / { 239 | {{ if eq $proto "uwsgi" }} 240 | include uwsgi_params; 241 | uwsgi_pass {{ trim $proto }}://{{ trim $upstream_name }}; 242 | {{ else if eq $proto "fastcgi" }} 243 | root {{ trim $vhost_root }}; 244 | include fastcgi.conf; 245 | fastcgi_pass {{ trim $upstream_name }}; 246 | {{ else }} 247 | proxy_pass {{ trim $proto }}://{{ trim $upstream_name }}; 248 | {{ end }} 249 | 250 | {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} 251 | auth_basic "Restricted {{ $host }}"; 252 | auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; 253 | {{ end }} 254 | {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} 255 | include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; 256 | {{ else if (exists "/etc/nginx/vhost.d/default_location") }} 257 | include /etc/nginx/vhost.d/default_location; 258 | {{ end }} 259 | } 260 | } 261 | 262 | {{ end }} 263 | 264 | {{ if or (not $is_https) (eq $https_method "noredirect") }} 265 | 266 | server { 267 | server_name {{ $host }}; 268 | listen 80 {{ $default_server }}; 269 | {{ if $enable_ipv6 }} 270 | listen [::]:80 {{ $default_server }}; 271 | {{ end }} 272 | access_log /var/log/nginx/access.log vhost; 273 | 274 | {{ if eq $network_tag "internal" }} 275 | # Only allow traffic from internal clients 276 | include /etc/nginx/network_internal.conf; 277 | {{ end }} 278 | 279 | {{ if (exists (printf "/etc/nginx/vhost.d/%s" $host)) }} 280 | include {{ printf "/etc/nginx/vhost.d/%s" $host }}; 281 | {{ else if (exists "/etc/nginx/vhost.d/default") }} 282 | include /etc/nginx/vhost.d/default; 283 | {{ end }} 284 | 285 | location / { 286 | {{ if eq $proto "uwsgi" }} 287 | include uwsgi_params; 288 | uwsgi_pass {{ trim $proto }}://{{ trim $upstream_name }}; 289 | {{ else if eq $proto "fastcgi" }} 290 | root {{ trim $vhost_root }}; 291 | include fastcgi.conf; 292 | fastcgi_pass {{ trim $upstream_name }}; 293 | {{ else }} 294 | proxy_pass {{ trim $proto }}://{{ trim $upstream_name }}; 295 | {{ end }} 296 | {{ if (exists (printf "/etc/nginx/htpasswd/%s" $host)) }} 297 | auth_basic "Restricted {{ $host }}"; 298 | auth_basic_user_file {{ (printf "/etc/nginx/htpasswd/%s" $host) }}; 299 | {{ end }} 300 | {{ if (exists (printf "/etc/nginx/vhost.d/%s_location" $host)) }} 301 | include {{ printf "/etc/nginx/vhost.d/%s_location" $host}}; 302 | {{ else if (exists "/etc/nginx/vhost.d/default_location") }} 303 | include /etc/nginx/vhost.d/default_location; 304 | {{ end }} 305 | } 306 | } 307 | 308 | {{ if (and (not $is_https) (exists "/etc/nginx/certs/default.crt") (exists "/etc/nginx/certs/default.key")) }} 309 | server { 310 | server_name {{ $host }}; 311 | listen 443 ssl http2 {{ $default_server }}; 312 | {{ if $enable_ipv6 }} 313 | listen [::]:443 ssl http2 {{ $default_server }}; 314 | {{ end }} 315 | access_log /var/log/nginx/access.log vhost; 316 | return 500; 317 | 318 | ssl_certificate /etc/nginx/certs/default.crt; 319 | ssl_certificate_key /etc/nginx/certs/default.key; 320 | } 321 | {{ end }} 322 | 323 | {{ end }} 324 | {{ end }} -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -H "Host: app1.example.com" localhost 4 | 5 | curl -H "Host: app2.example.com" localhost 6 | -------------------------------------------------------------------------------- /up.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | (cd router && docker-compose up -d) 4 | 5 | (cd app1 && docker-compose up -d) 6 | 7 | (cd app2 && docker-compose up -d) 8 | --------------------------------------------------------------------------------