├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── certbot.env ├── docker-compose.yml ├── flask.env ├── nginx ├── Dockerfile ├── bin │ ├── entrypoint.sh │ └── renew ├── conf.d │ └── app.conf └── nginx.conf └── src ├── Dockerfile ├── app └── __init__.py ├── gunicorn.conf.py ├── requirements.txt └── wsgi.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | __pycache__ 3 | *.tar 4 | *.swp 5 | *.log 6 | .venv 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Matthieu Petiteau 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help start stop start-local build 2 | 3 | help: ## Show this help menu 4 | @echo "Usage: make [TARGET ...]" 5 | @echo "" 6 | @grep --no-filename -E '^[a-zA-Z_%-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ 7 | awk 'BEGIN {FS = ":.*?## "}; {printf "%-15s %s\n", $$1, $$2}' 8 | 9 | start: stop build ## Start docker 10 | @docker compose up -d 11 | 12 | start-local: stop build ## Start docker for local dev (w/o nginx and certbot) 13 | @docker compose up --scale nginx=0 14 | 15 | stop: ## Stop docker 16 | @docker compose stop 17 | 18 | build: ## (re)build Docker images 19 | @docker compose build 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker + Nginx + Gunicorn + Flask + Let's Encrypt 2 | 3 | This repository is a Docker-based boilerplate for deploying a Flask web app with: 4 | 5 | - **Nginx** as a reverse proxy 6 | - **Gunicorn** as the WSGI server 7 | - **Flask** for the web application 8 | - **Let's Encrypt (Certbot)** for free HTTPS and auto renewals 9 | - **Docker** for easy deployment and reproducibility 10 | 11 | ## Docker Services 12 | 13 | | Service | Image | 14 | |---------|--------------------| 15 | | app | `python:3.13-slim` | 16 | | nginx | `nginx:alpine-slim` | 17 | 18 | --- 19 | 20 | ## Requirements 21 | 22 | | Dependency | Installation | 23 | |------------|--------------| 24 | | Docker | [Install Docker](https://docs.docker.com/engine/install/) | 25 | | Make | `sudo apt install make` | 26 | 27 | Other prerequisites: 28 | 29 | - A domain or subdomain with a DNS A record pointing to your server's static IP 30 | - Open ports **80** (HTTP) and **443** (HTTPS) 31 | 32 | ## Setup Instructions 33 | 34 | ### 1. Get the code on your server 35 | 36 | Use curl to download a compressed tarball of the repository from GitHub, so there is no need to install git on the server. 37 | 38 | ```bash 39 | curl -L https://github.com/smallwat3r/docker-nginx-gunicorn-flask-letsencrypt/archive/refs/heads/master.tar.gz | tar -xz 40 | cd docker-nginx-gunicorn-flask-letsencrypt-master 41 | ``` 42 | 43 | ### 2. Add your user to the Docker group 44 | 45 | ```bash 46 | sudo usermod -aG docker "${USER}" && newgrp docker 47 | ``` 48 | 49 | ### 3. Configure environment variables 50 | 51 | Edit the `certbot.env` file: 52 | 53 | - `EMAIL`: Email for Certbot (receive alerts) 54 | - `DOMAIN`: Your domain or subdomain 55 | 56 | Also you should check and update (if necessary): 57 | 58 | - `flask.env` for your Flask app environment variables 59 | - `src/gunicorn.conf.py` for Gunicorn settings 60 | 61 | ## Notes 62 | 63 | This is a **starter boilerplate**, meant to be customized. You’ll likely want to: 64 | 65 | - Replace the example app in `src/` 66 | - Update `src/Dockerfile` for your dependencies, or slightly change the Gunicorn command required to start your app 67 | - Edit `nginx/conf.d/app.conf` for your specific reverse proxy needs (e.g., CSP headers) 68 | 69 | ## Running the Application 70 | 71 | > You may need `sudo` to run Docker commands depending on your setup. 72 | 73 | ### Start the app 74 | 75 | ```bash 76 | make start 77 | ``` 78 | 79 | Your app should now be running online with HTTPS! 80 | 81 | > SSL certificates are automatically renewed weekly. 82 | 83 | ### All available commands 84 | 85 | ``` 86 | $ make help 87 | Usage: make [TARGET ...] 88 | 89 | help Show this help menu 90 | start Start docker 91 | start-local Start docker for local dev (w/o nginx and certbot) 92 | stop Stop docker 93 | build (re)build Docker images 94 | ``` 95 | 96 | ## License 97 | 98 | Licensed under the [MIT License](LICENSE). 99 | 100 | ## Support 101 | 102 | Have a question or issue? 103 | Open one on the [GitHub Issues page](https://github.com/smallwat3r/docker-nginx-gunicorn-flask-letsencrypt/issues) 104 | 105 | --- 106 | 107 | [![Buy me a coffee][buymeacoffee-shield]][buymeacoffee] 108 | 109 | [buymeacoffee-shield]: https://www.buymeacoffee.com/assets/img/guidelines/download-assets-sm-2.svg 110 | [buymeacoffee]: https://www.buymeacoffee.com/smallwat3r 111 | -------------------------------------------------------------------------------- /certbot.env: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # email to use with Certbot 4 | EMAIL=email@email.com 5 | # domain (or subdomain) linked to your server's static IP 6 | DOMAIN=mydomain.com 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | container_name: dngfl-nginx 4 | build: 5 | context: ./nginx 6 | args: 7 | # reference the app target in this compose file 8 | APP: app # do not change me 9 | env_file: 10 | # load Certbot configs 11 | - certbot.env 12 | ports: 13 | - 80:80 14 | - 443:443 15 | volumes: 16 | - /etc/letsencrypt:/etc/letsencrypt 17 | depends_on: 18 | - app 19 | app: 20 | container_name: dngfl-app 21 | build: 22 | context: ./src 23 | env_file: 24 | # load Flask env configs 25 | - flask.env 26 | ports: 27 | - 8080:8080 28 | -------------------------------------------------------------------------------- /flask.env: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # your flask env config values goes here 3 | 4 | FLASK_ENV=development 5 | -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:alpine-slim 2 | 3 | RUN echo 'https://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && \ 4 | apk update && apk add --no-cache certbot busybox-suid 5 | 6 | # replace existing files by our own configs 7 | RUN rm /etc/nginx/nginx.conf && rm /etc/nginx/conf.d/default.conf 8 | COPY nginx.conf /etc/nginx/ 9 | COPY ./conf.d/app.conf /etc/nginx/conf.d/ 10 | 11 | ARG APP 12 | # we're using `envsubst` to replace placeholders in the Nginx config. 13 | # to avoid conflicts, all actual dollar signs ($) in the config are replaced with ${DOLLAR}. 14 | # environment variables like DOMAIN and EMAIL should already be set via the certbot.env file. 15 | ENV APP="${APP}" DOLLAR='$' 16 | 17 | # location certbot will place the challenges for domain validation on renewal 18 | RUN mkdir -p /var/www/certbot/.well-known/acme-challenge 19 | 20 | COPY ./bin/entrypoint.sh /entrypoint.sh 21 | COPY ./bin/renew /etc/periodic/weekly/renew 22 | RUN chmod +x /entrypoint.sh /etc/periodic/weekly/renew 23 | 24 | ENTRYPOINT ["../entrypoint.sh"] 25 | -------------------------------------------------------------------------------- /nginx/bin/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | : "${DOMAIN:?DOMAIN is not set or is empty}" 4 | : "${EMAIL:?EMAIL is not set or is empty}" 5 | 6 | # use envsubst to fill placeholders in the Nginx app config file, 7 | # see nginx/Dockerfile for more details about what this does. 8 | envsubst /etc/nginx/conf.d/app.subst.conf && 9 | rm /etc/nginx/conf.d/app.conf 10 | 11 | certbot certonly --standalone -d "${DOMAIN}" --email "${EMAIL}" -n --agree-tos --expand 12 | /usr/sbin/crond && /usr/sbin/nginx -g "daemon off;" 13 | -------------------------------------------------------------------------------- /nginx/bin/renew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | certbot renew --webroot --webroot-path /var/www/certbot/ --post-hook "/usr/sbin/nginx -s reload" 4 | -------------------------------------------------------------------------------- /nginx/conf.d/app.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | # remove www if using a subdomain 5 | server_name www.${DOMAIN} ${DOMAIN}; 6 | # serve Certbot challenges 7 | location /.well-known/acme-challenge/ { 8 | root /var/www/certbot; 9 | } 10 | location / { 11 | return 301 https://${DOMAIN}${DOLLAR}request_uri; 12 | } 13 | } 14 | 15 | server { 16 | listen 443 ssl default_server; 17 | listen [::]:443 ssl default_server; 18 | http2 on; 19 | # remove www if using a subdomain 20 | server_name www.${DOMAIN} ${DOMAIN}; 21 | 22 | ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem; 23 | ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem; 24 | ssl_trusted_certificate /etc/letsencrypt/live/${DOMAIN}/chain.pem; 25 | 26 | add_header X-Frame-Options "SAMEORIGIN" always; 27 | add_header X-XSS-Protection "1; mode=block" always; 28 | add_header X-Content-Type-Options "nosniff" always; 29 | add_header Referrer-Policy "no-referrer-when-downgrade" always; 30 | add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; 31 | add_header Permissions-Policy "geolocation=(), microphone=(), camera=()"; 32 | 33 | # you might want to change the CSP to fit your needs - see https://content-security-policy.com/ 34 | add_header Content-Security-Policy "default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self'; style-src 'self';"; 35 | 36 | access_log /var/log/nginx/${DOMAIN}.access.log; 37 | error_log /var/log/nginx/${DOMAIN}.error.log warn; 38 | 39 | location / { 40 | proxy_http_version 1.1; 41 | proxy_cache_bypass ${DOLLAR}http_upgrade; 42 | proxy_hide_header X-Powered-By; 43 | proxy_hide_header Server; 44 | proxy_hide_header X-AspNetMvc-Version; 45 | proxy_hide_header X-AspNet-Version; 46 | proxy_set_header Proxy ""; 47 | proxy_set_header Upgrade ${DOLLAR}http_upgrade; 48 | proxy_set_header Connection "upgrade"; 49 | proxy_set_header Host ${DOLLAR}host; 50 | proxy_set_header X-Real-IP ${DOLLAR}remote_addr; 51 | proxy_set_header X-Forwarded-For ${DOLLAR}proxy_add_x_forwarded_for; 52 | proxy_set_header X-Forwarded-Proto ${DOLLAR}scheme; 53 | proxy_set_header X-Forwarded-Host ${DOLLAR}host; 54 | proxy_set_header X-Forwarded-Port ${DOLLAR}server_port; 55 | proxy_pass http://${APP}:8080; 56 | } 57 | 58 | location ~* \.(?:css|cur|js|jpe?g|gif|htc|ico|png|html|xml|otf|ttf|eot|woff|woff2|svg)${DOLLAR} { 59 | expires 7d; 60 | add_header Pragma public; 61 | add_header Cache-Control public; 62 | proxy_pass http://${APP}:8080; 63 | } 64 | 65 | if (${DOLLAR}http_user_agent ~* LWP::Simple|BBBike|wget) { 66 | return 403; 67 | } 68 | 69 | location ~ /\.(?!well-known) { 70 | deny all; 71 | } 72 | 73 | gzip on; 74 | gzip_vary on; 75 | gzip_min_length 1024; 76 | gzip_proxied any; 77 | gzip_comp_level 6; 78 | gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; 79 | } 80 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | 3 | worker_processes auto; 4 | worker_rlimit_nofile 8192; 5 | 6 | error_log /var/log/nginx/error.log warn; 7 | pid /var/run/nginx.pid; 8 | 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | http { 14 | charset utf-8; 15 | sendfile on; 16 | tcp_nopush on; 17 | tcp_nodelay on; 18 | server_tokens off; 19 | log_not_found off; 20 | types_hash_max_size 2048; 21 | client_max_body_size 16M; 22 | keepalive_timeout 65; 23 | types_hash_bucket_size 64; 24 | include mime.types; 25 | default_type application/octet-stream; 26 | resolver 8.8.8.8 8.8.4.4 valid=300s; 27 | resolver_timeout 5s; 28 | 29 | ssl_protocols TLSv1.2 TLSv1.3; 30 | ssl_ecdh_curve X25519:secp384r1; 31 | ssl_session_timeout 1d; 32 | ssl_session_cache shared:SSL:50m; 33 | ssl_session_tickets off; 34 | ssl_ciphers 'TLS13+AESGCM+AES128:TLS13+AESGCM+AES256:TLS13+CHACHA20:EECDH+AESGCM:EECDH+CHACHA20'; 35 | 36 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 37 | '$status $body_bytes_sent "$http_referer" ' 38 | '"$http_user_agent" "$http_x_forwarded_for"'; 39 | 40 | access_log /var/log/nginx/access.log main; 41 | 42 | # include configs for our app 43 | include conf.d/*.conf; 44 | } 45 | -------------------------------------------------------------------------------- /src/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-slim 2 | 3 | # you might want to install some required dependencies by your app here 4 | # here is an example: 5 | # RUN apt-get update && apt-get install -y \ 6 | # gcc \ 7 | # libpq-dev \ 8 | # && rm -rf /var/lib/apt/lists/* 9 | 10 | ENV PYTHONDONTWRITEBYTECODE=1 \ 11 | PYTHONUNBUFFERED=1 \ 12 | PIP_NO_CACHE_DIR=1 \ 13 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 14 | PIP_DEFAULT_TIMEOUT=100 15 | 16 | WORKDIR /app 17 | 18 | # create and use a non root user 19 | RUN groupadd --system appuser && \ 20 | useradd --system --create-home --gid appuser --shell /sbin/nologin appuser 21 | USER appuser 22 | ENV PATH="/home/appuser/.local/bin:${PATH}" 23 | 24 | COPY requirements.txt . 25 | RUN pip install --user -r requirements.txt 26 | 27 | COPY --chown=appuser:appuser . . 28 | 29 | CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"] 30 | -------------------------------------------------------------------------------- /src/app/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | 3 | 4 | def create_app() -> Flask: 5 | app = Flask(__name__) 6 | 7 | @app.route("/") 8 | def index(): 9 | return "Hello, world!" 10 | 11 | return app 12 | -------------------------------------------------------------------------------- /src/gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | # gunicorn configuration goes here 2 | bind = "0.0.0.0:8080" # do not change me 3 | workers = 3 4 | worker_class = "sync" 5 | timeout = 30 6 | accesslog = "-" 7 | errorlog = "-" 8 | loglevel = "info" 9 | -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | gunicorn 3 | -------------------------------------------------------------------------------- /src/wsgi.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | app = create_app() 4 | --------------------------------------------------------------------------------