├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── ansible └── roles │ ├── base │ └── tasks │ │ └── main.yml │ ├── django │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ ├── tasks │ │ └── main.yml │ └── templates │ │ ├── app.sh.j2 │ │ ├── app.systemd.service.j2 │ │ └── env.sh.j2 │ ├── nginx │ ├── defaults │ │ └── main.yml │ ├── handlers │ │ └── main.yml │ ├── tasks │ │ └── main.yml │ └── templates │ │ ├── nginx_django.conf.j2 │ │ └── nginx_django_ssl.conf.j2 │ ├── opensmtpd │ └── tasks │ │ └── main.yml │ ├── postgres │ └── tasks │ │ └── main.yml │ └── ufw │ └── tasks │ └── main.yml └── management ├── __init__.py └── commands ├── __init__.py └── up.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 Brenton Cleeland 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-up 2 | 3 | `django-up` is a tool to quickly deploy your Django application to a Ubuntu 22.04 server with almost zero configuration. 4 | 5 | ```shell 6 | python manage.py up django-up.com --email= 7 | ``` 8 | 9 | Running `django-up` will deploy a production ready, SSL-enabled, Django application to a VPS using: 10 | 11 | - Nginx 12 | - Gunicorn 13 | - PostgreSQL 14 | - SSL with acme.sh (using Let's Encrypt) 15 | - UFW 16 | - OpenSMTPd 17 | 18 | 19 | ## Supporting this project 20 | 21 | The easiest way to support the development of this project is to use [my Linode referal code][linode] if you need a hosting provider. 22 | By using this link you will receive a $100, 60-day credit once a valid payment method is added. 23 | If you spend $25 I will receive $25 credit in my account. 24 | 25 | `django-up` costs around $7/month to host on Linode, referrals cover that cost, plus help to support my other projects hosted there. I've used various hosting providers over the years but Linode is the one that I like the most. 26 | 27 | _This is the only place where referral codes are used. All other links in the documentation will take you to the services without my reference._ 28 | 29 | 30 | ## Quick Start (with Pipenv) 31 | 32 | Create a new VPS with your preferred provider and update your domain's DNS records to point at it. 33 | Check that you can SSH to the new server as `root` before continuing. 34 | 35 | Ensure that `ansible` is installed on the system your are deploying from. 36 | 37 | Create a directory for your new project and `cd` into it: 38 | 39 | ```shell 40 | mkdir testproj 41 | cd testproj 42 | ``` 43 | 44 | Install Django, PyYAML and dj_database_url: 45 | 46 | ```shell 47 | pipenv install Django pyyaml dj_database_url 48 | ``` 49 | 50 | Start a new Django project: 51 | 52 | ```shell 53 | pipenv run django-admin startproject testproj . 54 | ``` 55 | 56 | Run `git init` to initialise the new project as a git repository: 57 | 58 | ```shell 59 | git init 60 | ``` 61 | 62 | Add `django-up` as a git submodule: 63 | 64 | ```shell 65 | git submodule add git@github.com:sesh/django-up.git up 66 | ``` 67 | 68 | Add `up` to your `INSTALLED_APPS` to enable the management command: 69 | 70 | ```python 71 | INSTALLED_APPS = [ 72 | # ... 73 | 'up', 74 | ] 75 | ``` 76 | 77 | Add your target domain to the `ALLOWED_HOSTS` in your `settings.py`. 78 | 79 | ```python 80 | ALLOWED_HOSTS = [ 81 | 'djup-test.brntn.me', 82 | 'localhost' 83 | ] 84 | ``` 85 | 86 | Set the `SECURE_PROXY_SSL_HEADER` setting in your `settings.py` to ensure the connection is considered secure. 87 | 88 | ```python 89 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_SCHEME', 'https') 90 | ``` 91 | 92 | Set up your database to use `dj_database_url`: 93 | 94 | ```python 95 | import dj_database_url 96 | DATABASES = { 97 | 'default': dj_database_url.config(default=f'sqlite:///{BASE_DIR / "db.sqlite3"}') 98 | } 99 | ``` 100 | 101 | Generate a new secret key (either manually, or with a [trusted tool](https://utils.brntn.me/django-secret/)), and configure your application to pull it out of the environment. 102 | 103 | In `.env`: 104 | 105 | ``` 106 | DJANGO_SECRET_KEY= 107 | ``` 108 | 109 | And in your `settings.py` replace the existing `SECRET_KEY` line with this: 110 | 111 | ``` 112 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 113 | ``` 114 | 115 | Create a requirements file from your environment if one doesn't exist: 116 | 117 | ```shell 118 | pipenv requirements --exclude-markers > requirements.txt 119 | ``` 120 | 121 | Deploy with the `up` management command: 122 | 123 | ```shell 124 | pipenv run python manage.py up yourdomain.example --email= 125 | ``` 126 | 127 | 128 | ## Extra Configuration 129 | 130 | ### Setting environment variables 131 | 132 | Add environment variables to a `.env` file alongside your `manage.py`. These will be exported into the environment before running your server (and management commands during deployment). 133 | 134 | For example, to configure Django to load the `SECRET_KEY` from your environment, and add a secure secret key to your `.env` file: 135 | 136 | `settings.py`: 137 | 138 | ```python 139 | SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] 140 | ``` 141 | 142 | `.env`: 143 | 144 | ``` 145 | DJANGO_SECRET_KEY="dt(t9)7+&cm$nrq=p(pg--i)#+93dffwt!r05k-isd^8y1y0" 146 | ``` 147 | 148 | 149 | ### Specifying a Python version 150 | 151 | By default, `django-up` uses Python 3.8. 152 | If your application targets a different version you can use the `UP_PYTHON_VERSION` environment variable. 153 | Valid choices are: 154 | 155 | - `python3.8` 156 | - `python3.9` 157 | - `python3.10` (default) 158 | - `python3.11` 159 | 160 | ```python 161 | UP_PYTHON_VERSION = "python3.11" 162 | ``` 163 | 164 | These are the Python version available in the deadsnakes PPA. 165 | Versions older than Python 3.8 require older versions of OpenSSL so are not included in the PPA for Ubuntu 22.04. 166 | 167 | 168 | ### Deploying multiple applications to the same server 169 | 170 | Your application will bind to an internal port on your server. 171 | To deploy multiple applications to the same server you will need to manually specify this port. 172 | 173 | In your `settings.py`, set `UP_GUNICORN_PORT` is set to a unique port for the server that you are deploying to: 174 | 175 | ```python 176 | UP_GUNICORN_PORT = 8556 177 | ``` 178 | 179 | 180 | ### Using manifest file storage 181 | 182 | To minimise downtime, during the deployment `collectstatic` is executed while your previous deployment is still running. 183 | In order make sure that the correct version of static files are used _during the deployment_ you can use the `ManifestStaticFilesStorage` storage backend that Django provides. 184 | 185 | ```python 186 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 187 | ``` 188 | 189 | For most projects using this backend will be a best practice, regardless of whether you are deploying with `django-up`. 190 | 191 | 192 | ### Supporting multiple domains 193 | 194 | As long as all domains that you plan on supporting are pointing to your server, you can include them in your `ALLOWED_HOSTS`. 195 | Certificates will be requested for each domain. 196 | 197 | For example, so support both the apex and `www` subdomain for a project, your could configure your application with: 198 | 199 | ```python 200 | ALLOWED_HOSTS = [ 201 | 'django-up.com', 202 | 'www.django-up.com' 203 | ] 204 | ``` 205 | 206 | 207 | ### Adding `django-up` directly to your project 208 | 209 | If you are likely to customise the Ansible files then it's probably easier to just add the `django-up` files to your own git repository, rather than using a submodule. 210 | 211 | You can use a shell one liner to download the repository from Github and extract it into an "up" directory in your project: 212 | 213 | ```shell 214 | mkdir -p up && curl -L https://github.com/sesh/django-up/tarball/main | tar -xz --strip-components=1 -C up 215 | ``` 216 | 217 | 218 | [django]: https://www.djangoproject.com 219 | [linode]: https://www.linode.com/lp/refer/?r=46340a230dfd33a24e40407c7ea938e31b295dec 220 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/django-up/6528187cdb6bccf694de0cf7422d904ee80e335d/__init__.py -------------------------------------------------------------------------------- /ansible/roles/base/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Exit if target is not Ubuntu 22.04 4 | meta: end_play 5 | when: ansible_distribution_release not in ["jammy"] 6 | 7 | 8 | - name: Add Deadsnakes Nightly APT repository 9 | apt_repository: 10 | repo: ppa:deadsnakes/ppa 11 | 12 | 13 | - apt: update_cache=yes 14 | - apt: upgrade=dist 15 | 16 | 17 | - name: Install base packages 18 | apt: 19 | name: 20 | - build-essential 21 | - "{{ python_version }}" 22 | - "{{ python_version }}-dev" 23 | - "{{ python_version }}-distutils" 24 | - "{{ python_version }}-venv" 25 | - virtualenvwrapper 26 | - libpq-dev 27 | - libjpeg-dev 28 | - zlib1g-dev 29 | state: latest 30 | 31 | 32 | - name: Add app user group 33 | group: 34 | name: "{{ app_name }}" 35 | system: yes 36 | state: present 37 | 38 | 39 | - name: Add app user 40 | user: 41 | name: "{{ app_name }}" 42 | groups: 43 | - "{{ app_name }}" 44 | state: present 45 | append: yes 46 | shell: /bin/bash 47 | 48 | 49 | - name: Install acme.sh 50 | shell: curl https://get.acme.sh | sh -s email={{ certbot_email }} 51 | 52 | 53 | - name: Make directories for application 54 | file: path={{ item }} state=directory owner={{ app_name }} group=staff 55 | with_items: 56 | - /srv/www/{{ app_name }} 57 | - /srv/www/{{ app_name }}/logs 58 | - /srv/www/{{ app_name }}/static 59 | - /srv/www/{{ app_name }}/media 60 | - /srv/www/{{ app_path }} 61 | - /srv/www/{{ app_path }}/code 62 | - /srv/www/{{ app_path }}/logs 63 | -------------------------------------------------------------------------------- /ansible/roles/django/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | gunicorn_port: 9000 4 | gunicorn_workers: 4 5 | python_version: python3.10 6 | -------------------------------------------------------------------------------- /ansible/roles/django/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Restart app 4 | service: 5 | name: "{{ service_name }}" 6 | state: restarted 7 | enabled: yes 8 | -------------------------------------------------------------------------------- /ansible/roles/django/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Check if there is a previous DB password saved 4 | shell: "cat /srv/www/{{ app_name }}/.dbpass" 5 | ignore_errors: yes 6 | register: dot_dbpass 7 | 8 | 9 | - name: Replace the random DB password with the one from the .dbpass file 10 | set_fact: 11 | db_password: "{{ dot_dbpass.stdout }}" 12 | when: dot_dbpass.stdout != "" 13 | 14 | 15 | - name: Merge django_environment and our DATABASE_URL for environment 16 | set_fact: 17 | env: "{{ django_environment|combine({'DATABASE_URL': 'postgres://{{ app_name }}:{{ db_password }}@localhost:5432/{{ app_name }}'}) }}" 18 | 19 | 20 | - name: Copy application files to server 21 | copy: src={{ app_tar }} dest=/tmp/{{ app_path }}.tar 22 | 23 | 24 | - name: Create temporary directory 25 | file: path=/tmp/{{ app_path }}/code state=directory 26 | 27 | 28 | - name: Extract code 29 | unarchive: src=/tmp/{{ app_path }}.tar dest=/tmp/{{ app_path }}/code copy=no owner={{ app_name }} group={{ app_name }} 30 | 31 | 32 | - name: Set Django's static root 33 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="STATIC_ROOT = '/srv/www/{{ app_name }}/static/'" regexp="^STATIC_ROOT" 34 | 35 | 36 | - name: Set Django's media root 37 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="MEDIA_ROOT = '/srv/www/{{ app_name }}/media/'" regexp="^MEDIA_ROOT" 38 | 39 | 40 | - name: Set Django DEBUG=False 41 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="DEBUG = False" regexp="^DEBUG =" 42 | when: django_debug == "no" 43 | 44 | 45 | - name: Set Django DEBUG=True 46 | lineinfile: dest=/tmp/{{ app_path }}/code/{{ app_name }}/settings.py line="DEBUG = True" regexp="^DEBUG =" 47 | when: django_debug == "yes" 48 | 49 | 50 | - name: Add app.sh file 51 | template: src=app.sh.j2 dest=/srv/www/{{ app_path }}/{{ app_name }}.sh owner={{ app_name }} group={{ app_name }} mode=ug+x 52 | 53 | 54 | - name: Add env.sh file 55 | template: src=env.sh.j2 dest=/srv/www/{{ app_path }}/env.sh owner={{ app_name }} group={{ app_name }} mode=ug+x 56 | 57 | 58 | - name: Ensure latest pip 59 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=pip state=latest virtualenv_python={{ python_version }} 60 | 61 | 62 | - name: Ensure latest gunicorn 63 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=gunicorn state=latest virtualenv_python={{ python_version }} 64 | 65 | 66 | - name: Ensure latest psycopg2 67 | pip: virtualenv=/srv/www/{{ app_path }}/venv name=psycopg2-binary state=latest virtualenv_python={{ python_version }} 68 | 69 | 70 | - name: Recreate code directory 71 | file: path=/srv/www/{{ app_path }}/code state=directory 72 | 73 | 74 | - name: Copy code to /srv/ 75 | copy: src=/tmp/{{ app_path }}/code dest=/srv/www/{{ app_path }} remote_src="yes" owner={{ app_name }} group={{ app_name }} 76 | 77 | 78 | - name: Install requirements from requirements.txt 79 | pip: virtualenv=/srv/www/{{ app_path }}/venv requirements=/srv/www/{{ app_path }}/code/requirements.txt virtualenv_python={{ python_version }} 80 | 81 | 82 | - name: Django collect static 83 | django_manage: command=collectstatic app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 84 | environment: 85 | - "{{ env }}" 86 | ignore_errors: yes # this will fail if `staticfiles` is not in installed apps. That's okay. 87 | become: yes 88 | become_user: "{{ app_name }}" 89 | 90 | 91 | - name: Django create cache table 92 | django_manage: command=createcachetable app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 93 | environment: 94 | - "{{ env }}" 95 | ignore_errors: yes # this will fail if `CACHES` doesn't use DB caching 96 | become: yes 97 | become_user: "{{ app_name }}" 98 | 99 | 100 | # stop the service before we run migrate 101 | - name: Stop app 102 | service: name={{ service_name }} state=stopped 103 | ignore_errors: yes # service could be running 104 | 105 | 106 | # TODO: check if there are any migrations to run, don't stop service if there isn't 107 | - name: Django migrate 108 | django_manage: command=migrate app_path=/srv/www/{{ app_path }}/code/ virtualenv=/srv/www/{{ app_path }}/venv 109 | environment: 110 | - "{{ env }}" 111 | become: yes 112 | become_user: "{{ app_name }}" 113 | 114 | 115 | # Update the systemd config with our new service 116 | - name: Systemd config 117 | template: src=app.systemd.service.j2 dest=/etc/systemd/system/{{ service_name }}.service 118 | 119 | 120 | - name: Reload service 121 | service: name={{ service_name }} state=reloaded daemon_reload=yes 122 | 123 | 124 | - name: Start app 125 | service: name={{ service_name }} state=started enabled=true 126 | 127 | 128 | - name: Clean up old deployments 129 | shell: find /srv/www/ -type d -name "{{ app_name }}-*" ! -name "{{ app_path }}" -prune -exec rm -r "{}" \; 130 | ignore_errors: yes 131 | -------------------------------------------------------------------------------- /ansible/roles/django/templates/app.sh.j2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | LOGFILE=/srv/www/{{ app_name }}/logs/{{ app_path }}.log 5 | LOGDIR=$(dirname $LOGFILE) 6 | NUM_WORKERS={{ gunicorn_workers }} 7 | 8 | # user/group to run as 9 | USER={{ app_name }} 10 | GROUP={{ app_name }} 11 | 12 | {% for variable_name, value in env.items() %} 13 | export {{ variable_name }}="{{ value }}" 14 | {% endfor %} 15 | 16 | cd /srv/www/{{ app_path }}/code 17 | source /srv/www/{{ app_path }}/venv/bin/activate 18 | 19 | test -d $LOGDIR || mkdir -p $LOGDIR 20 | 21 | exec gunicorn {{ app_name }}.wsgi:application -w $NUM_WORKERS \ 22 | --timeout=300 --user=$USER --group=$GROUP --log-level=debug \ 23 | -b [::]:{{ gunicorn_port }} --log-file=$LOGFILE 2>> $LOGFILE 24 | -------------------------------------------------------------------------------- /ansible/roles/django/templates/app.systemd.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Runner for {{ app_name }} 3 | After=network.target 4 | 5 | [Service] 6 | User={{ app_name }} 7 | Group={{ app_name }} 8 | WorkingDirectory=/srv/www/{{ app_path }}/ 9 | ExecStart=/srv/www/{{ app_path }}/{{ app_name }}.sh 10 | ExecReload=/bin/kill -s HUP $MAINPID 11 | ExecStop=/bin/kill -s TERM $MAINPID 12 | PrivateTmp=true 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /ansible/roles/django/templates/env.sh.j2: -------------------------------------------------------------------------------- 1 | {% for variable_name, value in env.items() %} 2 | export {{ variable_name }}="{{ value }}" 3 | {% endfor %} 4 | 5 | . /srv/www/{{ app_path }}/venv/bin/activate 6 | -------------------------------------------------------------------------------- /ansible/roles/nginx/defaults/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | nginx_timeout: 120 4 | -------------------------------------------------------------------------------- /ansible/roles/nginx/handlers/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Reload nginx 4 | service: name=nginx state=reloaded 5 | 6 | 7 | - name: Restart nginx 8 | service: name=nginx state=restarted enabled=yes 9 | 10 | -------------------------------------------------------------------------------- /ansible/roles/nginx/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Apt update 4 | apt: update_cache=yes 5 | 6 | 7 | - name: Install nginx 8 | apt: name=nginx state=latest 9 | notify: 10 | - Restart nginx 11 | 12 | 13 | - name: Ensure the challenges directory exists 14 | file: path=/var/www/challenges/ state=directory 15 | 16 | 17 | - name: Ensure the acme.sh/nginx certs directory exists 18 | file: path=/etc/acme.sh/live/{{ domain }} state=directory 19 | 20 | 21 | # Check if there is already a certificate installed for {{ domain }} 22 | - name: Find the latest SSL certificate for this domain 23 | shell: "ls /etc/acme.sh/live/{{ domain }} | tail -n 1" 24 | register: cert_check 25 | 26 | # If no cert: 27 | # Add nginx config without SSL 28 | - name: Add nginx config (No SSL) 29 | template: src=nginx_django.conf.j2 dest=/etc/nginx/sites-available/{{ app_name }}.conf 30 | when: cert_check.stdout == "" 31 | 32 | - name: Link nginx config (No SSL) 33 | file: src=/etc/nginx/sites-available/{{ app_name }}.conf dest=/etc/nginx/sites-enabled/{{ app_name }}.conf state=link 34 | when: cert_check.stdout == "" 35 | 36 | - name: Reload nginx 37 | service: name=nginx state=reloaded 38 | when: cert_check.stdout == "" 39 | 40 | # Use acme.sh to request a certificate 41 | - name: Use acme.sh to request a certificate 42 | shell: /root/.acme.sh/acme.sh --issue {{ certbot_domains }} --server letsencrypt -w /var/www/challenges/ 43 | when: cert_check.stdout == "" 44 | 45 | # Use acme.sh to "install" the certificate 46 | - name: Install the certificates with acme.sh 47 | shell: /root/.acme.sh/acme.sh --install-cert {{ certbot_domains }} \ 48 | --key-file /etc/acme.sh/live/{{ domain }}/key.pem \ 49 | --fullchain-file /etc/acme.sh/live/{{ domain }}/cert.pem \ 50 | --reloadcmd "service nginx force-reload" 51 | when: cert_check.stdout == "" 52 | 53 | 54 | # Check if there is already a certificate installed for {{ domain }} 55 | - name: Find the latest SSL certificate for this domain 56 | shell: "ls /etc/acme.sh/live/ | grep -i ^{{ domain }}$ | tail -n 1" 57 | register: cert_check 58 | 59 | # If cert: 60 | # Just setup the SSL config 61 | 62 | - name: Add nginx config (with SSL) 63 | template: src=nginx_django_ssl.conf.j2 dest=/etc/nginx/sites-available/{{ app_name }}.conf 64 | when: cert_check.stdout != "" 65 | notify: 66 | - Reload nginx 67 | - Restart nginx 68 | 69 | 70 | - name: Link nginx config (with SSL) 71 | file: src=/etc/nginx/sites-available/{{ app_name }}.conf dest=/etc/nginx/sites-enabled/{{ app_name }}.conf state=link 72 | when: cert_check.stdout != "" 73 | notify: 74 | - Reload nginx 75 | - Restart nginx 76 | -------------------------------------------------------------------------------- /ansible/roles/nginx/templates/nginx_django.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name {{ domain_names }}; 4 | 5 | client_max_body_size 50M; 6 | 7 | # no security problem here, since / is alway passed to upstream 8 | root /srv/www/{{ app_name }}/code/{{ app_name }}; 9 | 10 | # always serve this directory for settings up let's encrypt 11 | location /.well-known/acme-challenge/ { 12 | root /var/www/challenges/; 13 | try_files $uri =404; 14 | } 15 | 16 | # favicon 17 | location /favicon.ico { 18 | log_not_found off; 19 | root /srv/www/{{ app_name }}/static/; 20 | expires 24h; 21 | gzip on; 22 | gzip_types image/x-icon; 23 | } 24 | 25 | # serve directly - analogous for static/staticfiles 26 | location /static/ { 27 | root /srv/www/{{ app_name }}/; 28 | gzip on; 29 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject text/html application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 30 | expires 24h; 31 | } 32 | 33 | location /media/ { 34 | root /srv/www/{{ app_name }}/; 35 | gzip on; 36 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject text/html application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 37 | expires 24h; 38 | } 39 | 40 | location / { 41 | proxy_pass_header Server; 42 | proxy_set_header Host $host; 43 | proxy_redirect off; 44 | proxy_set_header X-Real-IP $remote_addr; 45 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 46 | proxy_set_header X-Scheme $scheme; 47 | proxy_connect_timeout {{ nginx_timeout }}; 48 | proxy_read_timeout {{ nginx_timeout }}; 49 | proxy_pass http://localhost:{{ gunicorn_port }}/; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /ansible/roles/nginx/templates/nginx_django_ssl.conf.j2: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl http2; 3 | listen [::]:443 ssl http2; 4 | server_name {{ domain_names }}; 5 | 6 | ssl_certificate /etc/acme.sh/live/{{ cert_check.stdout }}/cert.pem; 7 | ssl_certificate_key /etc/acme.sh/live/{{ cert_check.stdout }}/key.pem; 8 | 9 | ssl_session_timeout 1d; 10 | ssl_session_cache shared:MozSSL:10m; # about 40000 sessions 11 | ssl_session_tickets off; 12 | 13 | # modern settings from the Mozilla SSL config generator 14 | # https://mozilla.github.io/server-side-tls/ssl-config-generator/ 15 | # intermediate configuration 16 | ssl_protocols TLSv1.2 TLSv1.3; 17 | ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; 18 | ssl_prefer_server_ciphers off; 19 | 20 | ssl_stapling on; 21 | ssl_stapling_verify on; 22 | 23 | # enable hsts 24 | add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload"; 25 | 26 | client_max_body_size 50M; 27 | 28 | # no security problem here, since / is alway passed to upstream 29 | root /srv/www/{{ app_name }}/code/{{ app_name }}; 30 | 31 | # favicon 32 | location /favicon.ico { 33 | log_not_found off; 34 | root /srv/www/{{ app_name }}/static/; 35 | expires 24h; 36 | gzip on; 37 | gzip_types image/x-icon; 38 | } 39 | 40 | # serve directly - analogous for static/staticfiles 41 | location /static/ { 42 | root /srv/www/{{ app_name }}/; 43 | gzip on; 44 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 45 | expires 24h; 46 | } 47 | 48 | location /media/ { 49 | root /srv/www/{{ app_name }}/; 50 | gzip on; 51 | gzip_types application/eot application/x-otf application/font application/x-perl application/font-sfnt application/x-ttf application/javascript font/eot application/json font/ttf application/opentype font/otf application/otf font/opentype application/pkcs7-mime image/svg+xml application/truetype text/css application/ttf text/csv application/vnd.ms-fontobject application/xhtml+xml text/javascript application/xml text/js application/xml+rss text/plain application/x-font-opentype text/richtext application/x-font-truetype text/tab-separated-values application/x-font-ttf text/xml application/x-httpd-cgi text/x-script application/x-javascript text/x-component application/x-mpegurl text/x-java-source application/x-opentype; 52 | expires 24h; 53 | } 54 | 55 | location / { 56 | proxy_pass_header Server; 57 | proxy_set_header Host $host; 58 | proxy_redirect off; 59 | proxy_set_header X-Real-IP $remote_addr; 60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 61 | proxy_set_header X-Scheme $scheme; 62 | proxy_connect_timeout {{ nginx_timeout }}; 63 | proxy_read_timeout {{ nginx_timeout }}; 64 | proxy_pass http://localhost:{{ gunicorn_port }}/; 65 | } 66 | } 67 | 68 | server { 69 | listen 80; 70 | listen [::]:80; 71 | server_name {{ domain_names }}; 72 | 73 | location /.well-known/acme-challenge/ { 74 | root /var/www/challenges/; 75 | try_files $uri =404; 76 | } 77 | 78 | location / { 79 | return 301 https://$server_name$request_uri; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /ansible/roles/opensmtpd/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Install OpenSMTPd 4 | apt: name=opensmtpd state=latest 5 | -------------------------------------------------------------------------------- /ansible/roles/postgres/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - apt: update_cache=yes 4 | 5 | 6 | - name: Install postgresql 7 | apt: 8 | pkg: 9 | - postgresql 10 | - postgresql-client 11 | - python3-psycopg2 12 | 13 | 14 | - name: Ensure postgres is running 15 | service: 16 | name: postgresql 17 | state: started 18 | 19 | 20 | - name: Create our database 21 | postgresql_db: 22 | name: "{{ app_name }}" 23 | encoding: "Unicode" 24 | template: "template0" 25 | become: yes 26 | become_user: postgres 27 | 28 | 29 | - name: Check if there is a previous DB password saved 30 | shell: "cat /srv/www/{{ app_name }}/.dbpass" 31 | ignore_errors: yes 32 | register: dot_dbpass 33 | 34 | 35 | - name: Replace the random DB password with the one from the .dbpass file 36 | set_fact: 37 | db_password: "{{ dot_dbpass.stdout }}" 38 | when: dot_dbpass.stdout != "" 39 | 40 | 41 | - name: Create the database user for this app 42 | postgresql_user: 43 | name: "{{ app_name }}" 44 | db: "{{ app_name }}" 45 | password: "{{ db_password }}" 46 | become: yes 47 | become_user: postgres 48 | 49 | 50 | - name: Save the db password for next time 51 | copy: 52 | content: "{{ db_password }}" 53 | dest: "/srv/www/{{ app_name }}/.dbpass" 54 | -------------------------------------------------------------------------------- /ansible/roles/ufw/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | - name: Ensure latest UFW 4 | apt: name=ufw state=latest 5 | 6 | - ufw: policy=allow direction=outgoing 7 | - ufw: policy=deny direction=incoming 8 | 9 | - ufw: rule=allow port=22 10 | - ufw: rule=allow port=80 proto=tcp 11 | - ufw: rule=allow port=443 proto=tcp 12 | 13 | - ufw: state=enabled 14 | -------------------------------------------------------------------------------- /management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/django-up/6528187cdb6bccf694de0cf7422d904ee80e335d/management/__init__.py -------------------------------------------------------------------------------- /management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sesh/django-up/6528187cdb6bccf694de0cf7422d904ee80e335d/management/commands/__init__.py -------------------------------------------------------------------------------- /management/commands/up.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | 3 | import os 4 | import random 5 | import shutil 6 | import string 7 | import subprocess 8 | import sys 9 | import tempfile 10 | 11 | import yaml 12 | from django.conf import settings 13 | from django.core.management.base import BaseCommand 14 | from django.core.validators import ValidationError, validate_email 15 | from django.utils.crypto import get_random_string 16 | 17 | """ 18 | Deploying Django applications as quickly as you create them 19 | 20 | Usage: 21 | ./manage.py up [--email=] [--debug] [--verbose] 22 | """ 23 | 24 | 25 | class Command(BaseCommand): 26 | help = "Deploy your Django site to a remote server" 27 | 28 | def add_arguments(self, parser): 29 | parser.add_argument("hostnames", nargs="+", type=str) 30 | parser.add_argument("--email", nargs=1, type=str, dest="email") 31 | parser.add_argument("--domain", nargs=1, type=str, dest="domain") 32 | parser.add_argument("--debug", action="store_true", default=False, dest="debug") 33 | parser.add_argument( 34 | "--verbose", action="store_true", default=False, dest="verbose" 35 | ) 36 | 37 | def handle(self, *args, **options): 38 | ansible_dir = os.path.join(os.path.dirname(__file__), "..", "..", "ansible") 39 | hostnames = options["hostnames"] 40 | email = options["email"] 41 | 42 | try: 43 | if email: 44 | email = email[0] 45 | else: 46 | email = os.environ.get("UP_EMAIL", None) 47 | validate_email(email) 48 | except (ValidationError, IndexError, TypeError): 49 | sys.exit( 50 | "The --email argument or UP_EMAIL environment variable must be set for the SSL certificate request" 51 | ) 52 | 53 | try: 54 | open("requirements.txt", "r") 55 | except FileNotFoundError: 56 | sys.exit( 57 | "requirements.txt not found in the root directory, use `pip freeze` or `pipenv lock --requirements` to generate." 58 | ) 59 | 60 | app_name = settings.WSGI_APPLICATION.split(".")[0] 61 | 62 | up_dir = tempfile.TemporaryDirectory().name + "/django_up" 63 | app_tar = tempfile.NamedTemporaryFile(suffix=".tar") 64 | 65 | # copy our ansible files into our up_dir 66 | shutil.copytree(ansible_dir, up_dir) 67 | 68 | # Build up the django_environment variable from the contents of the .env 69 | # file on the local machine. These environment variables are injected into 70 | # the running environment using the app.sh file that's created. 71 | django_environment = {} 72 | try: 73 | with open(os.path.join(settings.BASE_DIR, ".env")) as env_file: 74 | for line in env_file.readlines(): 75 | if line and "=" in line and line.strip()[0] not in ["#", ";"]: 76 | var, val = line.split("=", 1) 77 | if " " not in var: 78 | django_environment[var] = val.strip() 79 | else: 80 | print("Ignoring environment variable with space: ", line) 81 | print("Loaded environment from .env: ", django_environment.keys()) 82 | except FileNotFoundError: 83 | pass 84 | 85 | # create a tarball of our application code, excluding some common directories 86 | # and files that are unlikely to be wanted on the remote machine 87 | subprocess.call( 88 | [ 89 | "tar", 90 | "--exclude", 91 | "*.pyc", 92 | "--exclude", 93 | ".git", 94 | "--exclude", 95 | "*.sqlite3", 96 | "--exclude", 97 | "__pycache__", 98 | "--exclude", 99 | "*.log", 100 | "--exclude", 101 | "{}.tar".format(app_name), 102 | "--dereference", 103 | "-cf", 104 | app_tar.name, 105 | ".", 106 | ] 107 | ) 108 | 109 | # use allowed_hosts to set up our domain names 110 | domains = [] 111 | 112 | if options["domain"]: 113 | domains = options["domain"] 114 | else: 115 | for host in settings.ALLOWED_HOSTS: 116 | if host.startswith("."): 117 | domains.append("*" + host) 118 | elif "." in host and not host.startswith("127."): 119 | domains.append(host) 120 | 121 | for h in hostnames: 122 | if h not in domains: 123 | sys.exit( 124 | "{} isn't in allowed domains or DJANGO_ALLOWED_HOSTS".format(h) 125 | ) 126 | 127 | yam = [ 128 | { 129 | "hosts": app_name, 130 | "remote_user": "root", 131 | "gather_facts": "yes", 132 | "vars": { 133 | # app_name is used for our user, database and to refer to our main application folder 134 | "app_name": app_name, 135 | # app_path is the directory for this specific deployment 136 | "app_path": app_name 137 | + "-" 138 | + str(get_random_string(6, string.ascii_letters + string.digits)), 139 | # service_name is our systemd service (you cannot have _ or other special characters) 140 | "service_name": app_name.replace("_", ""), 141 | "domain_names": " ".join(domains), 142 | "certbot_domains": "-d " + " -d ".join(domains), 143 | "gunicorn_port": getattr(settings, "UP_GUNICORN_PORT", "9000"), 144 | "app_tar": app_tar.name, 145 | "python_version": getattr( 146 | settings, "UP_PYTHON_VERSION", "python3.8" 147 | ), 148 | # create a random database password to use for the database user, this is 149 | # saved on the remote machine and will be overridden by the ansible run 150 | # if it exists 151 | "db_password": str( 152 | get_random_string(12, string.ascii_letters + string.digits) 153 | ), 154 | "django_debug": "yes" if options["debug"] else "no", 155 | "django_environment": django_environment, 156 | "certbot_email": email, 157 | "domain": domains[0], 158 | }, 159 | "roles": ["base", "ufw", "opensmtpd", "postgres", "nginx", "django"], 160 | } 161 | ] 162 | 163 | app_yml = open(os.path.join(up_dir, "{}.yml".format(app_name)), "w") 164 | yaml.dump(yam, app_yml) 165 | 166 | # create the hosts file for ansible 167 | with open(os.path.join(up_dir, "hosts"), "w") as hosts_file: 168 | hosts_file.write("[{}]\n".format(app_name)) 169 | hosts_file.write("\n".join(hostnames)) 170 | 171 | # add any extra ansible arguments that we need 172 | ansible_args = [] 173 | if options["verbose"]: 174 | ansible_args.append("-vvvv") 175 | 176 | # build the ansible command 177 | command = ["ansible-playbook", "-i", os.path.join(up_dir, "hosts")] 178 | command.extend(ansible_args) 179 | command.extend([os.path.join(up_dir, "{}.yml".format(app_name))]) 180 | 181 | # execute ansible 182 | return_code = subprocess.call(command) 183 | sys.exit(return_code) 184 | --------------------------------------------------------------------------------