Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ db.sqlite3
.idea
/media
.vagrant
*.env
*.env.*
.vscode
geckodriver.exe
geckodriver.log
Expand All @@ -21,3 +21,5 @@ requirements.txt
.vs
.qodo
ssl
ansible/inventory.yml
google-credentials.json
30 changes: 30 additions & 0 deletions ansible/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Ansible Deployment

Minimal Ansible playbook to deploy the BLT Django project.

## Files
- `inventory.yml` - Define your server host/IP and variables.
- `playbook.yml` - Executes deployment steps (clone repo, install deps, migrate, collectstatic, configure systemd + nginx).

## Usage
1. Edit `inventory.yml` and set:
- `ansible_host`
- `ansible_user`
- `domain` (optional)
2. Run:
```bash
ansible-playbook -i ansible/inventory.yml ansible/playbook.yml
```

## Notes
- Installs dependencies using Poetry export to a requirements.txt installed into a virtualenv.
- Creates a systemd service `gunicorn-blt`.
- Nginx reverse proxies to Gunicorn on port 8000.
Comment on lines +21 to +22
Copy link

Copilot AI Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation incorrectly references 'gunicorn-blt' service and Gunicorn server, but the playbook actually creates a 'blt-uvicorn' service using uvicorn ASGI server. Update documentation to match the actual implementation.

Suggested change
- Creates a systemd service `gunicorn-blt`.
- Nginx reverse proxies to Gunicorn on port 8000.
- Creates a systemd service `blt-uvicorn`.
- Nginx reverse proxies to uvicorn (ASGI) server on port 8000.

Copilot uses AI. Check for mistakes.

- Opens ports 22, 80, 443 with UFW.
Comment on lines +21 to +23
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Align README with the actual service (Uvicorn, not Gunicorn).

The playbook provisions a systemd unit named blt-uvicorn that runs uvicorn. The README still references a gunicorn-blt service and Gunicorn proxying, which will mislead anyone operating this deployment. Please update the doc to match the playbook.

🤖 Prompt for AI Agents
ansible/README.md lines 21-23: the README mentions a systemd service named
`gunicorn-blt` and that Nginx reverse proxies to Gunicorn on port 8000, but the
playbook actually provisions `blt-uvicorn` and runs Uvicorn; update the three
bullet points to reference the `blt-uvicorn` systemd service, state that Nginx
reverse proxies to Uvicorn (adjust port if different in the playbook, e.g., 8000
or as configured), and ensure any instructions about service names, sockets, or
systemd unit files match the playbook’s filenames and commands; also replace any
remaining “Gunicorn” mentions with “Uvicorn”.

- For HTTPS, you can manually install Certbot or extend the playbook.

## Quick Certbot (optional)
```bash
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your.domain --email [email protected] --agree-tos --redirect
```
271 changes: 271 additions & 0 deletions ansible/playbook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
---
- name: Deploy BLT Django App
hosts: blt_server
become: true
vars:
python_version: "3.11"
env_file: .env.production
service_name: blt-uvicorn
django_settings: blt.settings
listen_interface: 127.0.0.1
enable_remote_postgres: "{{ enable_remote_postgres | default(true) }}"
pre_tasks:
Comment on lines +11 to +12
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Lock down PostgreSQL remote access by default.

enable_remote_postgres currently defaults to true and we always open UFW port 5432 plus append a pg_hba rule for 0.0.0.0/0. On a fresh run this exposes the database listener to the entire internet with just password auth, which is a critical security regression.

Please make remote access opt‑in (default false) and gate both the pg_hba change and the firewall rule behind that flag. Example:

-    enable_remote_postgres: "{{ enable_remote_postgres | default(true) }}"
+    enable_remote_postgres: "{{ enable_remote_postgres | default(false) }}"
...
-    - name: Open firewall ports
+    - name: Open firewall ports
       ufw:
         rule: allow
         port: "{{ item }}"
       loop:
         - '22'
         - '80'
         - '443'
-        - '5432'
+    - name: Allow PostgreSQL port when remote access enabled
+      ufw:
+        rule: allow
+        port: '5432'
+      when: enable_remote_postgres | bool

That keeps the database private unless the operator explicitly opts in.

Also applies to: 157-171, 236-244

🤖 Prompt for AI Agents
In ansible/playbook.yml around lines 11-12 (and similarly at 157-171 and
236-244), change the enable_remote_postgres default from true to false and gate
both the UFW rule that opens port 5432 and the pg_hba.conf rule append behind
this flag; update variable definition to default(false), wrap the tasks that add
the pg_hba entry and the ufw rule in when: enable_remote_postgres | bool, and
ensure any handlers or conditionals that reload/restart PostgreSQL only run when
the flag is true so remote access is opt-in and not exposed by default.

- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600

- name: Ensure system packages (app + PostgreSQL)
apt:
name:
- git
- python3-pip
- python3-venv
- build-essential
- libpq-dev
- python3-psycopg2
- postgresql
- postgresql-contrib
- nginx
- ufw
- curl
state: present

- name: Add app user with home
user:
name: "{{ app_user }}"
system: yes
create_home: yes
shell: /bin/bash

tasks:
- name: Create app directory structure
file:
path: "{{ item }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0755'
loop:
- "{{ app_dir }}"
- "{{ app_dir }}/shared"
- "{{ app_dir }}/shared/logs"
- "{{ app_dir }}/shared/media"

- name: Clone or update repository
git:
repo: "{{ repo_url }}"
dest: "{{ app_dir }}/current"
version: "{{ branch }}"
force: yes
register: git_result

- name: Ensure virtualenv
command: python3 -m venv {{ venv_dir }}
args:
creates: "{{ venv_dir }}/bin/activate"

- name: Upgrade pip/setuptools/wheel
pip:
name:
- pip
- setuptools
- wheel
state: latest
virtualenv: "{{ venv_dir }}"

- name: Install Python dependencies (poetry export)
shell: |
if command -v poetry >/dev/null 2>&1; then
cd {{ app_dir }}/current && poetry export -f requirements.txt --without-hashes -o /tmp/requirements.txt
else
cd {{ app_dir }}/current && pip install poetry && poetry export -f requirements.txt --without-hashes -o /tmp/requirements.txt
fi
{{ venv_dir }}/bin/pip install -r /tmp/requirements.txt
args:
executable: /bin/bash

- name: Create production env file (.env.production)
copy:
dest: "{{ app_dir }}/current/{{ env_file }}"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0600'
content: |
DEBUG=False
ALLOWED_HOSTS=.{{ domain }},{{ domain }},127.0.0.1,localhost
SECRET_KEY={{ lookup('password', '/dev/null', length=64, chars='ascii_letters,digits') }}
Copy link

Copilot AI Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SECRET_KEY is regenerated on every playbook run because it uses '/dev/null' as the password file. This will invalidate user sessions and potentially cause Django to reject existing signed data. Use a persistent file path like '/tmp/django_secret_key' or generate it once and store it securely.

Suggested change
SECRET_KEY={{ lookup('password', '/dev/null', length=64, chars='ascii_letters,digits') }}
SECRET_KEY={{ lookup('password', '{{ app_dir }}/shared/secret_key', length=64, chars='ascii_letters,digits') }}

Copilot uses AI. Check for mistakes.

DATABASE_URL=postgres://{{ postgres_db_user }}:{{ postgres_db_password }}@127.0.0.1:5432/{{ postgres_db_name }}
PORT=8000
# Add any extra environment variables below
# SENTRY_DSN=
# OPENAI_API_KEY=
notify: Restart app

Comment on lines +88 to +104
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Persist the Django SECRET_KEY instead of regenerating every run.

Using lookup('password', '/dev/null', ...) creates a brand-new secret on every playbook execution. That forces a service restart each time and invalidates all Django sessions/tokens, even when nothing else changed.

Please persist the generated value (e.g., write it once to a file under {{ app_dir }}/shared/secret_key and reuse it on subsequent runs) so reruns stay idempotent. Example:

-          SECRET_KEY={{ lookup('password', '/dev/null', length=64, chars='ascii_letters,digits') }}
+          SECRET_KEY={{ lookup('password', '{{ app_dir }}/shared/secret_key', length=64, chars='ascii_letters,digits') }}

This keeps the secret stable across deployments while still generating it on first run.

🤖 Prompt for AI Agents
In ansible/playbook.yml around lines 88-104, the SECRET_KEY is being regenerated
on every run using lookup('password', '/dev/null', ...), causing unnecessary
restarts and session invalidation; change the play so it first checks for a
persisted secret at {{ app_dir }}/shared/secret_key, if missing generate a new
secret and write it to that file with owner {{ app_user }} and mode 0600, then
read the secret from that file and insert its value into the .env.production
content (instead of calling lookup each run); ensure the task is idempotent
(only writes when file absent or empty) and that file permissions are secure so
subsequent playbook runs reuse the same SECRET_KEY.

- name: Symlink .env -> .env.production
file:
src: "{{ app_dir }}/current/{{ env_file }}"
dest: "{{ app_dir }}/current/.env"
state: link
owner: "{{ app_user }}"
group: "{{ app_user }}"
notify: Restart app

- name: Collect static files
shell: "{{ venv_dir }}/bin/python manage.py collectstatic --noinput"
args:
chdir: "{{ app_dir }}/current"
environment:
DJANGO_SETTINGS_MODULE: blt.settings

- name: Apply migrations
shell: "{{ venv_dir }}/bin/python manage.py migrate --noinput"
args:
chdir: "{{ app_dir }}/current"
environment:
DJANGO_SETTINGS_MODULE: blt.settings

- name: Ensure PostgreSQL service is started
service:
name: postgresql
state: started
enabled: yes

- name: Set fact for PostgreSQL main directory
shell: ls -d /etc/postgresql/*/main | head -n1
register: pg_main_dir
changed_when: false

- name: Register postgres conf paths
set_fact:
postgres_conf_dir: "{{ pg_main_dir.stdout }}"
postgres_conf_file: "{{ pg_main_dir.stdout }}/postgresql.conf"
postgres_hba_file: "{{ pg_main_dir.stdout }}/pg_hba.conf"

- name: Create PostgreSQL role if absent
become_user: postgres
shell: |
psql -tc "SELECT 1 FROM pg_roles WHERE rolname='{{ postgres_db_user }}'" | grep -q 1 || psql -c "CREATE ROLE {{ postgres_db_user }} LOGIN PASSWORD '{{ postgres_db_password }}';"
changed_when: false
Comment on lines +146 to +149
Copy link

Copilot AI Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PostgreSQL password is exposed in the command line and process list. Use the postgresql_user Ansible module instead of shell commands to securely create database users without exposing credentials.

Suggested change
become_user: postgres
shell: |
psql -tc "SELECT 1 FROM pg_roles WHERE rolname='{{ postgres_db_user }}'" | grep -q 1 || psql -c "CREATE ROLE {{ postgres_db_user }} LOGIN PASSWORD '{{ postgres_db_password }}';"
changed_when: false
community.postgresql.postgresql_user:
name: "{{ postgres_db_user }}"
password: "{{ postgres_db_password }}"
login: true
state: present
become_user: postgres

Copilot uses AI. Check for mistakes.


- name: Create PostgreSQL database if absent
become_user: postgres
shell: |
psql -tc "SELECT 1 FROM pg_database WHERE datname='{{ postgres_db_name }}'" | grep -q 1 || psql -c "CREATE DATABASE {{ postgres_db_name }} OWNER {{ postgres_db_user }};"
changed_when: false

- name: Allow PostgreSQL to listen on all addresses (listen_addresses)
lineinfile:
path: "{{ postgres_conf_file }}"
regexp: '^#?listen_addresses'
line: "listen_addresses = '*'"
when: enable_remote_postgres | bool
notify: Restart postgres

- name: Add remote access rule to pg_hba.conf
lineinfile:
path: "{{ postgres_hba_file }}"
insertafter: EOF
line: 'host all all 0.0.0.0/0 md5'
when: enable_remote_postgres | bool
notify: Restart postgres

- name: Create systemd service for uvicorn (ASGI)
copy:
dest: /etc/systemd/system/{{ service_name }}.service
content: |
[Unit]
Description=BLT ASGI (uvicorn)
After=network.target postgresql.service

[Service]
WorkingDirectory={{ app_dir }}/current
User={{ app_user }}
Group={{ app_user }}
Environment=DJANGO_SETTINGS_MODULE={{ django_settings }}
ExecStart={{ venv_dir }}/bin/uvicorn blt.asgi:application --host {{ listen_interface }} --port 8000 --workers {{ uvicorn_workers }}
Restart=always
KillSignal=SIGQUIT
TimeoutStopSec=30
SyslogIdentifier={{ service_name }}

[Install]
WantedBy=multi-user.target
notify: Restart app

- name: Configure Nginx site
copy:
dest: /etc/nginx/sites-available/blt.conf
content: |
server {
listen 80;
server_name {{ domain }} *.{{ domain }};

proxy_buffering off;

location /static/ {
alias {{ app_dir }}/current/static/;
}
location /media/ {
alias {{ app_dir }}/shared/media/;
}
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_pass http://127.0.0.1:8000;
}
}
notify: Reload nginx

- name: Enable nginx site
file:
src: /etc/nginx/sites-available/blt.conf
dest: /etc/nginx/sites-enabled/blt.conf
state: link
notify: Reload nginx

- name: Disable default nginx site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx

- name: Open firewall ports
ufw:
rule: allow
port: "{{ item }}"
loop:
- '22'
- '80'
- '443'
- '5432'
Comment on lines +240 to +244
Copy link

Copilot AI Oct 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostgreSQL port 5432 is opened in the firewall unconditionally, but remote PostgreSQL access is controlled by the 'enable_remote_postgres' variable. The firewall rule should only open port 5432 when remote access is actually enabled to reduce attack surface.

Suggested change
loop:
- '22'
- '80'
- '443'
- '5432'
loop: "{{ ['22', '80', '443'] + (enable_remote_postgres | bool | ternary(['5432'], [])) }}"

Copilot uses AI. Check for mistakes.


- name: Enable UFW if not enabled
command: ufw --force enable
register: ufw_enable
changed_when: ufw_enable.rc == 0

handlers:
- name: Restart app
systemd:
name: "{{ service_name }}"
state: restarted
daemon_reload: yes

- name: Restart postgres
systemd:
name: postgresql
state: restarted

- name: Reload nginx
systemd:
name: nginx
state: restarted

post_tasks:
- name: Display deployment summary
debug:
msg: "Deployment completed. Access http://{{ domain }} (wildcard subdomains enabled). PostgreSQL remote access={{ enable_remote_postgres }}"
Loading