├── tests ├── __init__.py └── test_patch.py ├── .gitignore ├── django_production ├── __init__.py ├── __main__.py └── settings.py ├── pytest.ini ├── .github └── workflows │ └── test.yml ├── CHANGELOG.md ├── LICENSE ├── pyproject.toml └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /.venv 3 | /.pytest_cache 4 | /.coverage 5 | -------------------------------------------------------------------------------- /django_production/__init__.py: -------------------------------------------------------------------------------- 1 | """django-production gets your project production ready""" 2 | 3 | __version__ = "0.1.0.dev0" 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = test_*.py *_tests.py 3 | addopts = 4 | -vv 5 | --durations=5 6 | testpaths = tests 7 | console_output_style = progress 8 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | --- 2 | on: [push] 3 | name: Test 4 | jobs: 5 | test: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | - uses: actions/setup-python@v3 10 | with: 11 | python-version: "3.10" 12 | - name: Install 13 | run: | 14 | python -m venv --upgrade-deps .venv 15 | . .venv/bin/activate 16 | pip install -e '.[test]' 17 | - name: Test 18 | run: | 19 | . .venv/bin/activate 20 | pytest --cov 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ### Fixed 11 | 12 | * Static files now work out-of-the-box and `whitenoise` is always used to serve static files, not just in production. 13 | 14 | ## [0.1.0] - 2020-10-28 15 | 16 | ### Added 17 | 18 | * Swapped out use of `environ.Env` for `environ.FileAwareEnv` to allow for loading environment variables from files in addition to standard environment variables. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Peter Baumgartner 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "django-production" 7 | readme = "README.md" 8 | authors = [{name = "Peter Baumgartner", email = "pete@lincolnloop.com"}] 9 | license = {file = "LICENSE"} 10 | classifiers = [ 11 | "Development Status :: 4 - Beta", 12 | "Intended Audience :: Developers", 13 | "License :: OSI Approved :: MIT License", 14 | "Programming Language :: Python :: 3", 15 | "Framework :: Django", 16 | ] 17 | dynamic = ["version", "description"] 18 | dependencies = [ 19 | "django-environ", 20 | "whitenoise", 21 | "django-webserver[gunicorn]", 22 | "django-alive", 23 | ] 24 | keywords = ["django", "production", "deployment"] 25 | 26 | [project.optional-dependencies] 27 | test = [ 28 | "pytest", 29 | "pytest-cov", 30 | "urllib3", 31 | ] 32 | 33 | [project.scripts] 34 | django-production-apply = "django_production.__main__:do_patch" 35 | 36 | [project.urls] 37 | Home = "https://github.com/lincolnloop/django-production" 38 | Issues = "https://github.com/lincolnloop/django-production/issues" 39 | Changelog = "https://github.com/lincolnloop/django-production/blob/main/CHANGELOG.md" 40 | 41 | [tool.flit.module] 42 | name = "django_production" 43 | -------------------------------------------------------------------------------- /django_production/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from importlib import import_module 4 | from pathlib import Path 5 | from string import Template 6 | from types import ModuleType 7 | 8 | import django 9 | 10 | START_MARKER = "\n# BEGIN: added by django-production" 11 | END_MARKER = "# END: added by django-production\n" 12 | 13 | 14 | def patch_settings(settings: ModuleType) -> None: 15 | settings_file = Path(settings.__file__) 16 | production_settings_file = Path(__file__).parent / "settings.py" 17 | if START_MARKER in settings_file.read_text(): 18 | raise RuntimeError( 19 | "It looks like this settings file already contains the django-production patch." 20 | ) 21 | # assuming top-level module is the project 22 | # used to determine where static files directories should live 23 | settings_module_depth = len(settings.__name__.split(".")) - 2 24 | if settings_module_depth < 0: 25 | settings_module_depth = 0 26 | settings_patch = Template(production_settings_file.read_text()).safe_substitute( 27 | settings_depth=str(settings_module_depth) 28 | ) 29 | with settings_file.open(mode="a") as f: 30 | f.write("\n".join([START_MARKER, settings_patch, END_MARKER])) 31 | 32 | 33 | def patch_urlconf(settings: ModuleType) -> None: 34 | urlconf = settings.ROOT_URLCONF 35 | urlconf_mod = import_module(urlconf) 36 | urlconf_file = Path(urlconf_mod.__file__) 37 | if START_MARKER in urlconf_file.read_text(): 38 | raise RuntimeError( 39 | "It looks like this urlconf file already contains the django-production patch." 40 | ) 41 | patch_parts = [ 42 | START_MARKER, 43 | "from django.urls import include", 44 | 'urlpatterns.insert(0, path("-/", include("django_alive.urls")))', 45 | END_MARKER, 46 | ] 47 | with urlconf_file.open(mode="a") as f: 48 | f.write("\n".join(patch_parts)) 49 | 50 | 51 | def do_patch(): 52 | # if Django project isn't installed, add the current directory to the Python path so it can be imported 53 | sys.path.insert(0, os.getcwd()) 54 | django.setup() 55 | settings = import_module(os.environ["DJANGO_SETTINGS_MODULE"]) 56 | patch_settings(settings) 57 | patch_urlconf(settings) 58 | -------------------------------------------------------------------------------- /django_production/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from pathlib import Path 4 | 5 | import environ 6 | 7 | env = environ.FileAwareEnv() 8 | 9 | DJANGO_ENV = env.str("DJANGO_ENV", "dev") 10 | 11 | INSTALLED_APPS.extend( 12 | [ 13 | "django_webserver", # Allow running webserver from manage.py 14 | "whitenoise.runserver_nostatic", # Use whitenoise with runserver 15 | ] 16 | ) 17 | 18 | # Insert whitenoise middleware 19 | try: 20 | MIDDLEWARE.insert( 21 | MIDDLEWARE.index("django.middleware.security.SecurityMiddleware") + 1, 22 | "whitenoise.middleware.WhiteNoiseMiddleware", 23 | ) 24 | except ValueError: 25 | MIDDLEWARE.insert(0, "whitenoise.middleware.WhiteNoiseMiddleware") 26 | 27 | # skip host checking for healthcheck URLs 28 | MIDDLEWARE.insert(0, "django_alive.middleware.healthcheck_bypass_host_check") 29 | 30 | X_FRAME_OPTIONS = "DENY" 31 | REFERRER_POLICY = "same-origin" 32 | 33 | # Staticfiles 34 | staticfiles_parent_dir = Path(__file__).resolve().parents[$settings_depth] 35 | STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage" 36 | STATICFILES_DIRS = [staticfiles_parent_dir / "static"] 37 | STATIC_ROOT = staticfiles_parent_dir / "static_collected" 38 | WHITENOISE_AUTOREFRESH = True 39 | WHITENOISE_IMMUTABLE_FILE_TEST = lambda path, url: re.match( # noqa: E731 40 | r"^.+\.[0-9a-f]{12}\..+$", url 41 | ) 42 | 43 | if DJANGO_ENV == "production": 44 | DEBUG = env.bool("DEBUG", default=False) 45 | WHITENOISE_AUTOREFRESH = False 46 | SECRET_KEY = env.str("SECRET_KEY") 47 | if "DATABASE_URL" in os.environ: 48 | DATABASES = {"default": env.db("DATABASE_URL")} 49 | ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=["*"]) 50 | # Load cache from CACHE_URL or REDIS_URL 51 | if "CACHE_URL" in os.environ: 52 | CACHES = {"default": env.cache("CACHE_URL")} 53 | elif "REDIS_URL" in os.environ: 54 | CACHES = {"default": env.cache("REDIS_URL")} 55 | # Security 56 | CSRF_COOKIE_SECURE = True 57 | SESSION_COOKIE_SECURE = True 58 | SECURE_BROWSER_XSS_FILTER = True 59 | SECURE_CONTENT_TYPE_NOSNIFF = True 60 | 61 | # HTTPS only behind a proxy that terminates SSL/TLS 62 | SECURE_SSL_REDIRECT = True 63 | SECURE_REDIRECT_EXEMPT = [r"^-/"] 64 | SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 65 | SECURE_HSTS_SECONDS = 31536000 66 | SECURE_HSTS_PRELOAD = True 67 | # Only set this to True if you are certain that all subdomains of your domain should be served exclusively via SSL. 68 | SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool( 69 | "SECURE_HSTS_INCLUDE_SUBDOMAINS", default=False 70 | ) 71 | -------------------------------------------------------------------------------- /tests/test_patch.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | import shutil 4 | import subprocess 5 | import time 6 | from contextlib import contextmanager 7 | from importlib import reload 8 | from pathlib import Path 9 | from typing import Dict, List 10 | 11 | import pytest 12 | import urllib3 13 | from django.core.exceptions import ImproperlyConfigured 14 | from django_production.__main__ import START_MARKER, do_patch 15 | 16 | 17 | @pytest.fixture(autouse=True) 18 | def setup_testproj(tmpdir_factory): 19 | """Create an empty project and set DJANGO_SETTINGS_MODULE""" 20 | orig_env = os.environ.get("DJANGO_SETTINGS_MODULE") 21 | orig_dir = os.getcwd() 22 | os.environ["DJANGO_SETTINGS_MODULE"] = "testproj.settings" 23 | tmpdir = tmpdir_factory.mktemp("test") 24 | os.chdir(tmpdir) 25 | subprocess.check_call(["django-admin", "startproject", "testproj"]) 26 | os.chdir("testproj") 27 | os.mkdir("testproj/static") 28 | yield 29 | os.chdir(orig_dir) 30 | shutil.rmtree(tmpdir) 31 | if orig_env is not None: 32 | os.environ["DJANGO_SETTINGS_MODULE"] = orig_env 33 | 34 | 35 | @pytest.fixture 36 | def django_env_prod(): 37 | """Set expected production environment variables""" 38 | orig_env = os.environ.copy() 39 | os.environ.update( 40 | { 41 | "DJANGO_ENV": "production", 42 | "SECRET_KEY": secrets.token_urlsafe(60), 43 | "SECURE_HSTS_INCLUDE_SUBDOMAINS": "true", 44 | } 45 | ) 46 | yield 47 | os.environ = orig_env 48 | 49 | 50 | @contextmanager 51 | def start_server(cmd: List[str], env: Dict[str, str]): 52 | """Start and terminate a server process in the background""" 53 | proc = subprocess.Popen(cmd, env=env) 54 | try: 55 | time.sleep(2) 56 | yield 57 | finally: 58 | proc.terminate() 59 | 60 | 61 | def test_missing_env_var(): 62 | """Patching fails without DJANGO_SETTINGS_MODULE""" 63 | del os.environ["DJANGO_SETTINGS_MODULE"] 64 | with pytest.raises(ImproperlyConfigured): 65 | do_patch() 66 | 67 | 68 | def test_cli(): 69 | """CLI is installed properly""" 70 | subprocess.check_call(["django-production-apply"]) 71 | assert START_MARKER in Path("testproj/urls.py").read_text() 72 | assert START_MARKER in Path("testproj/settings.py").read_text() 73 | 74 | 75 | def test_idempotent(): 76 | """Patch is only applied once""" 77 | settings_file = Path("testproj/settings.py") 78 | orig_settings = settings_file.read_text() 79 | do_patch() 80 | with pytest.raises(RuntimeError): 81 | do_patch() 82 | settings_file.write_text(orig_settings) 83 | with pytest.raises(RuntimeError): 84 | do_patch() 85 | 86 | 87 | def test_python(): 88 | """Python code is functional after patching""" 89 | do_patch() 90 | from testproj import urls 91 | 92 | reload(urls) 93 | assert len(urls.urlpatterns) == 2 94 | 95 | from testproj import settings 96 | 97 | reload(settings) 98 | assert "django_webserver" in settings.INSTALLED_APPS 99 | 100 | 101 | def test_django_env_dev(): 102 | """Production settings are not applied in development""" 103 | do_patch() 104 | from testproj import settings 105 | 106 | reload(settings) 107 | assert settings.DEBUG is True 108 | assert not hasattr(settings, "CSRF_COOKIE_SECURE") 109 | 110 | 111 | def test_django_env_prod(django_env_prod): 112 | """Production settings are applied in production""" 113 | do_patch() 114 | from testproj import settings 115 | 116 | reload(settings) 117 | assert settings.DEBUG is False 118 | assert settings.CSRF_COOKIE_SECURE is True 119 | 120 | 121 | def test_django_check_deploy(django_env_prod): 122 | """django check --deploy passes after patching""" 123 | do_patch() 124 | # Use subprocess to make sure all the settings are loaded fresh post-patching 125 | subprocess.check_call(["./manage.py", "check", "--deploy", "--fail-level=DEBUG"]) 126 | 127 | 128 | def test_staticfiles_prod(django_env_prod): 129 | """Static files are served in production with cache headers""" 130 | Path("testproj/static/test.css").write_text("body { color: red; }") 131 | do_patch() 132 | subprocess.check_call(["./manage.py", "collectstatic", "--noinput"]) 133 | with start_server( 134 | cmd=["./manage.py", "gunicorn"], env={"WEB_CONCURRENCY": "1", **os.environ} 135 | ): 136 | http = urllib3.PoolManager() 137 | resp = http.request( 138 | "GET", 139 | "http://localhost:8000/static/test.f2b804d3e3bd.css", 140 | headers={"X-Forwarded-Proto": "https"}, 141 | redirect=False, 142 | ) 143 | assert resp.status == 200 144 | assert resp.headers["Cache-Control"] == "max-age=315360000, public, immutable" 145 | 146 | 147 | def test_staticfiles_dev(): 148 | """Static files are served in development""" 149 | Path("testproj/static/test.css").write_text("body { color: red; }") 150 | do_patch() 151 | with start_server(cmd=["./manage.py", "runserver"], env=dict(os.environ)): 152 | http = urllib3.PoolManager() 153 | resp = http.request( 154 | "GET", "http://localhost:8000/static/test.css", redirect=False 155 | ) 156 | assert resp.status == 200 157 | assert "Cache-Control" not in resp.headers 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-production 2 | 3 | Opinionated one-size-fits-most defaults for running Django to production (or any other deployed environment). 4 | 5 | ## Installation 6 | 7 | ``` 8 | pip install django-production 9 | DJANGO_SETTINGS_MODULE=yourproject.settings django-production-apply 10 | ``` 11 | 12 | ## What it does 13 | 14 | When you install the package, it will install the following dependencies: 15 | 16 | * `whitenoise` - for serving static files 17 | * `django-environ` - for reading settings from environment variables 18 | * `django-webserver[gunicorn]` - for running the webserver via `manage.py` 19 | * `django-alive` - for a health check endpoint at `/-/alive/` 20 | 21 | Running `django-production-apply` will append the `django-production` settings to your project's settings file and add the healthcheck endpoint to your project's `urlpatterns`. You can see the settings that are added in [settings.py](https://github.com/lincolnloop/django-production/blob/main/django_production/settings.py). 22 | 23 | You should add `django-production` to your requirements to keep the necessary dependencies in place. Alternatively, once the patch is applied, you're free to move the dependencies into your own requirements file and remove `django-production` altogether. 24 | 25 | By default, static files are loaded from apps and the `static` directory in your project. When building the project for deployment, you should run `manage.py collectstatic --noinput` to collect static files into the `static_collected` directory. In production, `whitenoise` will serve the files in that directory. Be sure you use the [`{% static %}` template tag](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#static) in your templates so you can take advantage of the `Cache-Control` header that `whitenoise` applies. You'll probably want to add `static_collected` to your `.gitignore` (or similar) file. 26 | 27 | ## Running in production 28 | 29 | Start the webserver with `python manage.py gunicorn`. 30 | 31 | Set the `WEB_CONCURRENCY` environment variable to the number of gunicorn workers you want to run. Start with 2x the number of CPU cores. 32 | 33 | ### Required environment variables 34 | 35 | * `DJANGO_ENV` - set to `production` to enable production settings 36 | * `SECRET_KEY` - a secret key for your project 37 | 38 | ### Optional environment variables when using `DJANGO_ENV=production` 39 | 40 | * `ALLOWED_HOSTS` - a comma-separated list of allowed hosts 41 | * `DEBUG` - defaults to `False` you probably don't want to change that 42 | * `DATABASE_URL` - a database URL (see https://django-environ.readthedocs.io/en/latest/types.html#environ-env-db-url) 43 | * `CACHE_URL` or `REDIS_URL` - a cache URL (see https://django-environ.readthedocs.io/en/latest/types.html#environ-env-cache-url) 44 | * `SECURE_HSTS_INCLUDE_SUBDOMAINS` - set this to `True` if your site doesn't have any subdomains that need to use HTTP 45 | 46 | Under the hood, `django-production` uses `django-environ`'s [`FileAwareEnv`](https://django-environ.readthedocs.io/en/latest/tips.html#docker-style-file-based-variables) class to read environment variables. This allows you to append `_FILE` to any environment variable to load the value from a file. For example, `DATABASE_URL_FILE=/var/run/secrets/DATABASE_URL` will load the database URL from that file. 47 | 48 | ## Answers 49 | 50 | You didn't ask any questions, but if you did, maybe it would be one of these: 51 | 52 | **Why did you write this?** 53 | Django takes an un-opinionated approach to how it should be deployed. This makes it harder for new users. Even experienced users probably copy this from project-to-project. This aims to make it easy to get a project ready to deploy. I also hope it will give us a chance to create some consensus around these settings as a community and maybe start folding some of this into Django itself. 54 | 55 | **Why are you writing to my settings file? You could just just do an import.** 56 | 1. It makes it easier to see the changes. I'm of the opinion that settings files should be as simple as possible. Having the settings right there makes it easier to debug. 57 | 2. A one-size-fits-all approach will never work here. I'm shooting for one-size-fits-most. Users are free to make changes however they see fit once the change is applied. It's basically what `startproject` is already doing. 58 | 59 | **I disagree with the settings/packages you're using.** 60 | Not a question, but ok. Feel free to submit an issue or pull request with your suggestion and reasoning. We appreciate the feedback and contributions. We may not accept changes that we don't feel fit the spirit of this project (remember, it's _opinionated_). If you're unsure, don't hesitate to ask. 61 | 62 | ## Contribute 63 | 64 | ### Setup 65 | 66 | To setup the project just install the dependencies with the tests dependencies included 67 | `$ pip install -e '.[test]'` 68 | 69 | if the command above doesn't work try this 70 | `$ pip install -e .[test]` 71 | 72 | ### run the tests 73 | 74 | `$ pytest` 75 | 76 | ## Publishing a new version 77 | 78 | 1. Update the version in `django_production/__init__.py` 79 | 2. Update the changelog in `CHANGELOG.md` 80 | 3. Commit the changes 81 | 4. Tag the commit with the version number (`git tag -s v0.9.9 -m v0.9.9`) 82 | 5. Push the commit and tag (`git push && git push --tags`) 83 | 6. Publish to PyPI `flit publish` 84 | 85 | ## To Do 86 | 87 | - Handle media settings for common object stores 88 | - Email settings including non-SMTP backends like SES 89 | --------------------------------------------------------------------------------