Deploying web app with Nginx and Cloudflare

Kamal MustafaKamal Mustafa
3 min read

This setup is for Django app but can also applies to other platform as well. The general setup before we get into details:-

  • Run django with gunicorn

  • Manage the gunicorn process with systemd

  • Nginx as reverse proxy that forward incoming requests to gunicorn

  • Nginx serve https, http requests will be redirected to https

  • Nginx use Cloudflare origin certificate for the https - the cert expiry date is 2038 so we don’t have to worry about renewing the cert

  • Configure server’s firewall to only accept HTTP/S connections from cloudflare only. Cloudflare publish list of their IPs that we can scrape and pass it to our firewall config.

For the nginx config, we want to achieve the following:-

  • The site can be protected with basic auth (useful for pre-launch) but certain paths can be exempted - for external webhooks

  • Serve static files such as js. css and images directly

  • Redirect http requests to https

  • Set X-Forwarded-For to client actual IP

Here’s the example nginx config:-

server {
        #listen 80 default_server;
        listen [::]:80 default_server;
        client_max_body_size 2M;

        # SSL configuration
        #
        listen 443 ssl default_server;
        listen [::]:443 ssl default_server;
        ssl_certificate /etc/ssl/certs/cloudflare.pem;
        ssl_certificate_key     /etc/ssl/private/cloudflare.key;

        index index.html index.htm index.nginx-debian.html;

        server_name _;

        location @backend {
                real_ip_header CF-Connecting-IP;
                proxy_set_header Host $host;
                proxy_set_header X-Forwarded-For $http_CF_Connecting_IP;
                proxy_pass http://127.0.0.1:8000;
        }

        location /static/ {
                alias /app/appname/current/public/;
                #expires 30d;
                add_header Cache-Control public;
        }
        location /media/ {
                alias /app/appname/media/;
                #expires 30d;
                add_header Cache-Control public;
        }

        location / {
                #auth_basic "Restricted Area";
                # auth_basic_user_file /etc/nginx/htpasswd;

                location /stripe/ {
                        auth_basic off;
                        try_files $uri @backend;
                }
                location /paddle/callback/ {
                        auth_basic off;
                        try_files $uri @backend;
                }
                # First attempt to serve request as file, then
                # as directory, then fall back to displaying a 404.
                # try_files $uri $uri/ =404;
                try_files $uri @backend;
        }
}


# Virtual Host configuration for example.com
#
# You can move that to a different file under sites-available/ and symlink that
# to sites-enabled/ to enable it.
#
server {
        listen 80;
        listen [::]:80;

        server_name example.com;
        return 301 https://$server_name$request_uri;
}

And this is the systemd unit file:-

[Unit]
Description=appname daemon
After=network.target

[Service]
User=appname
Group=appname
WorkingDirectory=/app/appname/current
Environment="PYTHONPATH=$PYTHONPATH:/app/appname/current/src"
ExecStart=/app/appname/current/.venv/bin/gunicorn -t 60 --workers 5 appname.wsgi:application

[Install]
WantedBy=multi-user.target

Add this at /etc/systemd/system/appname.service.

Here’s the script to scrape cloudflare’s IPs and update Lightsail firewall to allow connections from that IP to port 80 and 443. When using Lightsail you can run this script from Cloudshell in the same region. It super convenient as you don’t have to worry about AWS credentials, the cloudshell instance already assume IAM roles and have full access (as of your IAM permissions) to AWS resources.

import boto3
import requests

# Set up the Lightsail client
client = boto3.client('lightsail')

# Define the instance name
instance_name = 'appname-01'

resp = requests.get("https://www.cloudflare.com/ips-v4/")
ips = resp.text.split("\n")
print(ips)
# Define the ports and allowed IPs
port_info = [
    {
        'fromPort': 80,
        'toPort': 80,
        'protocol': 'tcp',
        'cidrs': ips,  # Example IP ranges
        'cidrListAliases': []  # You can use predefined IP lists here if needed
    },
    {
        'fromPort': 443,
        'toPort': 443,
        'protocol': 'tcp',
        'cidrs': ips,  # Example IP ranges
        'cidrListAliases': []  # You can use predefined IP lists here if needed
    },
    {
        'fromPort': 22,
        'toPort': 22,
        'protocol': 'tcp',
        'cidrs': ["0.0.0.0/0"],  # Example IP ranges
        'cidrListAliases': []
    }
]

# Apply the new firewall rules
response = client.put_instance_public_ports(
    instanceName=instance_name,
    portInfos=port_info
)

Ending notes

Make sure to never leak your origin server ip address. Common mistake is to have another entry in dns pointing to the server’s ip, such as MX record. If you need to receive emails, use different server for that.

0
Subscribe to my newsletter

Read articles from Kamal Mustafa directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Kamal Mustafa
Kamal Mustafa

I am a web developer focusing on building web application using Python and Django. Full profile on https://kamal.koditi.my/.