Skip to content
Closed
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
8 changes: 8 additions & 0 deletions ansible/ansible.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[defaults]
inventory = hosts
pipelining = True
# host_key_checking = False # use default secure behavior (commented to fall back to default)
interpreter_python = /usr/bin/python3

[ssh_connection]
retries = 3
36 changes: 36 additions & 0 deletions ansible/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/bin/bash
set -e
# Change to script directory
cd "$(dirname "$0")"

# ─── Load env file from project root if present ────────────────────────────
PROJECT_ROOT="$(cd .. && pwd)"
if [[ -f "$PROJECT_ROOT/.env" ]]; then
# shellcheck disable=SC1091
set -a
source "$PROJECT_ROOT/.env"
set +a
fi

# ─── VPS connection details ───────────────────────────────────────────────
VPS_IP="${VPS_IP:-}"
VPS_USER="${VPS_USER:-root}"
VPS_PASSWORD="${VPS_PASSWORD:-}"

if [[ -z "$VPS_IP" ]]; then
echo "❌ VPS_IP not set (export or add to .env)." >&2
exit 1
fi

# Forward any extra args to ansible-playbook
EXTRA_ARGS=("$@")

ANSIBLE_CMD=(ansible-playbook -i "${VPS_IP}," -u "$VPS_USER" playbook.yml)

if [[ -n "$VPS_PASSWORD" ]]; then
ANSIBLE_CMD+=(--extra-vars "ansible_password=${VPS_PASSWORD}")
else
ANSIBLE_CMD+=(--ask-pass)
fi
Comment on lines +30 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

Passing a password on the command line via --extra-vars is a major security vulnerability. The password will be visible in the system's process list to other users on the same machine.

It's highly recommended to use SSH key-based authentication instead of passwords. If passwords are required, the existing --ask-pass is a much safer alternative as it uses a secure prompt.

Suggested change
if [[ -n "$VPS_PASSWORD" ]]; then
ANSIBLE_CMD+=(--extra-vars "ansible_password=${VPS_PASSWORD}")
else
ANSIBLE_CMD+=(--ask-pass)
fi
ANSIBLE_CMD+=(--ask-pass)


exec "${ANSIBLE_CMD[@]}" "${EXTRA_ARGS[@]}"
17 changes: 17 additions & 0 deletions ansible/education-website.service.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[Unit]
Description={{ project_name }} website
After=network.target

[Service]
User={{ vps_user }}
Group={{ vps_user }}
WorkingDirectory=/home/{{ vps_user }}/{{ project_name }}
Environment="PATH=/home/{{ vps_user }}/{{ project_name }}/venv/bin"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The Django application requires environment variables from the .env file to function correctly (e.g., SECRET_KEY, database settings). Systemd does not load this file by default, which will likely cause the application to fail at startup.

You should use the EnvironmentFile directive to instruct systemd to load variables from your project's .env file.

Environment="PATH=/home/{{ vps_user }}/{{ project_name }}/venv/bin"
EnvironmentFile=/home/{{ vps_user }}/{{ project_name }}/.env

ExecStart=/home/{{ vps_user }}/{{ project_name }}/venv/bin/gunicorn \
web.asgi:application -w 4 -k uvicorn.workers.UvicornWorker \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The number of gunicorn workers is hardcoded to 4. This may not be optimal for different server specifications. It's better to make this configurable via an Ansible variable to allow for easier tuning.

A common formula for the number of workers is (2 * number_of_cpu_cores) + 1.

         web.asgi:application -w {{ gunicorn_workers | default(4) }} -k uvicorn.workers.UvicornWorker \

--timeout 120 \
-b 127.0.0.1:{{ app_port }}
Restart=always

[Install]
WantedBy=multi-user.target
82 changes: 82 additions & 0 deletions ansible/env.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Django Core Settings
SECRET_KEY="{{ secret_key }}"
DEBUG="{{ debug }}"
ENVIRONMENT="production"

ALLOWED_HOSTS="{{ allowed_hosts }}"
CSRF_TRUSTED_ORIGINS="{{ csrf_trusted_origins }}"

# Database configuration (Production PostgreSQL)
DB_NAME="{{ db_name }}"
DB_USER="{{ db_user }}"
DB_PASSWORD="{{ db_password }}"
DB_HOST="localhost"
DB_PORT="5432"

# Static files
STATIC_ROOT="/home/{{ vps_user }}/{{ project_name }}/staticfiles"

# Email Configuration
{% if sendgrid_api_key %}
SENDGRID_API_KEY="{{ sendgrid_api_key }}"
{% endif %}
{% if sendgrid_password %}
SENDGRID_PASSWORD="{{ sendgrid_password }}"
{% endif %}
{% if email_from %}
EMAIL_FROM="{{ email_from }}"
{% endif %}

# Slack webhook
{% if slack_webhook_url %}
SLACK_WEBHOOK_URL="{{ slack_webhook_url }}"
{% endif %}

# Stripe configuration
{% if stripe_secret_key %}
STRIPE_SECRET_KEY="{{ stripe_secret_key }}"
{% endif %}
{% if stripe_publishable_key %}
STRIPE_PUBLISHABLE_KEY="{{ stripe_publishable_key }}"
{% endif %}
{% if stripe_webhook_secret %}
STRIPE_WEBHOOK_SECRET="{{ stripe_webhook_secret }}"
{% endif %}
{% if stripe_connect_webhook_secret %}
STRIPE_CONNECT_WEBHOOK_SECRET="{{ stripe_connect_webhook_secret }}"
{% endif %}

# Security & Encryption
{% if message_encryption_key %}
MESSAGE_ENCRYPTION_KEY="{{ message_encryption_key }}"
{% endif %}

# Admin Configuration
{% if admin_url %}
ADMIN_URL="{{ admin_url }}"
{% endif %}

# Google Cloud Storage Configuration
{% if gs_bucket_name %}
GS_BUCKET_NAME="{{ gs_bucket_name }}"
{% endif %}
{% if gs_project_id %}
GS_PROJECT_ID="{{ gs_project_id }}"
{% endif %}
{% if service_account_file %}
SERVICE_ACCOUNT_FILE="{{ service_account_file }}"
{% endif %}

# Django Debug (separate from DEBUG)
{% if django_debug %}
DJANGO_DEBUG="{{ django_debug }}"
{% endif %}

# Redis Configuration
{% if redis_url %}
REDIS_URL="{{ redis_url }}"
{% endif %}

# Cookie security overrides (for staging without HTTPS)
CSRF_COOKIE_SECURE="{{ csrf_cookie_secure }}"
SESSION_COOKIE_SECURE="{{ session_cookie_secure }}"
57 changes: 57 additions & 0 deletions ansible/nginx-http.conf.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
worker_connections 768;
}

http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;

include /etc/nginx/mime.types;
default_type application/octet-stream;

access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;

gzip on;

server {
listen 80 default_server;
{% if domain_name %}
server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %};
{% else %}
server_name _;
{% endif %}

location /.well-known/acme-challenge/ {
root /var/www/html;
}

location /static/ {
alias /home/{{ vps_user }}/{{ project_name }}/staticfiles/;
}

location /media/ {
alias /home/{{ vps_user }}/{{ project_name }}/media/;
}

location / {
proxy_pass http://127.0.0.1:{{ app_port }};
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
138 changes: 138 additions & 0 deletions ansible/nginx.conf.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
include /etc/nginx/modules-enabled/*.conf;

events {
worker_connections 768;
# multi_accept on;
}

http {
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;

include /etc/nginx/mime.types;
default_type application/octet-stream;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;

access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;

gzip on;

# Disable gzip for SSE endpoints
gzip_disable "MSIE [1-6]\.";

{% if enable_https_redirect == "True" and domain_name %}
server {
listen 80 default_server;
server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %};

# This block is for certbot http-01 challenge
location /.well-known/acme-challenge/ {
root /var/www/html;
}

location / {
return 301 https://$host$request_uri;
}
}

server {
listen 443 ssl http2;
server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %};

# SSL configuration managed by Certbot
ssl_certificate /etc/letsencrypt/live/{{ domain_name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ domain_name }}/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

location /static/ {
alias /home/{{ vps_user }}/{{ project_name }}/staticfiles/;
}

location /media/ {
alias /home/{{ vps_user }}/{{ project_name }}/media/;
}

# Special handling for SSE endpoints (admin logs)
location ~ ^/admin-[^/]+/logs/$ {
proxy_pass http://127.0.0.1:{{ app_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# SSE-specific settings
proxy_set_header Connection '';
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 24h;
proxy_send_timeout 24h;

# Disable gzip for SSE
gzip off;

# Add SSE headers
add_header Cache-Control 'no-cache, no-store, must-revalidate';
add_header Pragma 'no-cache';
add_header Expires '0';
add_header X-Accel-Buffering 'no';
}

location / {
proxy_pass http://127.0.0.1:{{ app_port }};
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host {{ domain_name | default('alphaonelabs.com') }};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Hardcoding the Host header with {{ domain_name | default('...') }} is not robust. It can lead to incorrect behavior if the server is accessed via other valid hostnames (e.g., www subdomains) that are also configured in server_name.

Using the Nginx variable $host is the standard and more flexible approach, as it dynamically passes the hostname from the original request. This is already done correctly in other parts of the configuration (e.g., line 68).

The same issue is present on line 131.

            proxy_set_header Host $host;

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# Standard proxy settings
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
}
}
{% else %}
server {
listen 80 default_server;
{% if domain_name %}
server_name {{ domain_name }}{% if domain_name and not domain_name.startswith('www.') %} www.{{ domain_name }}{% endif %};
{% else %}
server_name _;
{% endif %}

location /static/ {
alias /home/{{ vps_user }}/{{ project_name }}/staticfiles/;
}

location /media/ {
alias /home/{{ vps_user }}/{{ project_name }}/media/;
}

location / {
proxy_pass http://127.0.0.1:{{ app_port }};
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host {{ domain_name | default('alphaonelabs.com') }};
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
{% endif %}
}
Loading