# Production deployment runbook

Applies to **C-CARE v1.0.0 Release Candidate 2**. No real credential, hostname or
IP address appears in this document; every value below is a placeholder or a
variable name.

C-CARE is a Django 5.2 / WSGI application on PostgreSQL. It ships no task queue,
no cache server and no bundled web server. `manage.py runserver` is a development
aid only and must never be used in production.

## 0. Prerequisites and versions

| Component | Supported | Notes |
| --- | --- | --- |
| Python | 3.10 – 3.14 | Verified release candidate build: 3.14.4 |
| Django | 5.2.x (pinned in `requirements/base.txt`) | LTS branch; security patch releases only |
| PostgreSQL | 14 or later | Verified release candidate build: 18.6 |
| psycopg | 3.3.x driver | `psycopg[binary]` bundles libpq; use `psycopg[c]` only if a system libpq is present and newer |
| Reverse proxy | Any that terminates TLS and serves static files | nginx, Apache, Caddy or a cloud load balancer |

The application requires a single PostgreSQL database. `DATABASES` must contain
exactly the `default` alias; `audit_project` refuses to run otherwise because it
creates an isolated test database.

## 1. Prepare server and runtime

1. Install the supported Python version and PostgreSQL server.
2. Install system packages needed by libpq if you use `psycopg[c]`.
3. Confirm the timezone database is present; the project runs in `Asia/Dhaka`.
4. Confirm the system clock is synchronised (`timedatectl`, `chronyc`, or NTP).
   Job numbering, SLA deadlines and token expiry all depend on it.

## 2. Create the application user and directories

Run as a privileged account, never as the database role:

```
sudo useradd --system --create-home --home-dir /opt/ccare --shell /usr/sbin/nologin ccare
sudo mkdir -p /opt/ccare/app
sudo chown -R ccare:ccare /opt/ccare
```

Keep the checkout owned by the service account so `collectstatic` can write
`STATIC_ROOT`. Do not give the service account sudo.

## 3. Configure the environment

Supply configuration through the service manager (systemd `EnvironmentFile=`,
a container runtime, or a secret manager). C-CARE reads every setting from the
process environment. `.env` is a **development convenience only** and should not
exist on a production host.

| Variable | Required | Purpose |
| --- | --- | --- |
| `DJANGO_SETTINGS_MODULE` | set by WSGI entry point | `config.settings.production` is the default in `config/wsgi.py` and `config/asgi.py` |
| `DJANGO_SECRET_KEY` | **yes** | Independently generated, ≥50 characters, ≥5 distinct characters, never `django-insecure-` or a placeholder |
| `DJANGO_ALLOWED_HOSTS` | **yes** | Comma-separated hostnames the app answers for. `*` is refused |
| `DJANGO_CSRF_TRUSTED_ORIGINS` | if cross-origin | `https://host` entries only, scheme included |
| `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT` | **yes** | `DB_PASSWORD` may come from the environment; it falls back to a local `.env` for development only |
| `DJANGO_TRUSTED_PROXY_HEADER` | if behind TLS proxy | `NONE` (default), `X-FORWARDED-PROTO`, `X-FORWARDED-PROTOCOL` or `X-FORWARDED-SSL` |
| `DJANGO_HSTS_INCLUDE_SUBDOMAINS` | no | `True` only if **every** subdomain is HTTPS-only |
| `DJANGO_HSTS_PRELOAD` | no | `True` only if the whole domain is permanently HTTPS |
| `DJANGO_SECURE_REFERRER_POLICY` | no | Defaults to `same-origin` |
| `DJANGO_LOG_LEVEL` | no | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`; defaults to `INFO` |
| `COMMUNICATIONS_ALLOW_EXTERNAL` | **yes** for delivery | `False` keeps all outbound delivery disabled |
| `COMMUNICATIONS_EMAIL_BACKEND` | for email | `apps.communications.providers.DjangoEmailProvider` |
| `COMMUNICATIONS_SMS_BACKEND` | deferred | No production SMS adapter ships in RC2 |
| `EMAIL_BACKEND`, `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`, `EMAIL_USE_TLS`, `EMAIL_USE_SSL`, `DEFAULT_FROM_EMAIL` | for email | SMTP settings; TLS and SSL are mutually exclusive and credentials must be supplied as a pair |

Generate the secret without printing it into a log:

```
python -c 'import secrets; print(secrets.token_urlsafe(64))'
```

Production settings **fail at import** for a weak or placeholder secret, a
missing or wildcard `ALLOWED_HOSTS`, an invalid proxy header, an invalid log
level or an unknown referrer policy. A misconfigured host refuses to start
rather than serving traffic.

## 4. Configure PostgreSQL

```
sudo -u postgres createuser --no-createdb --no-superuser --no-pw ccare_app
sudo -u postgres createuser --no-createdb --no-superuser --pw ccare_backup
sudo -u postgres createdb --owner=ccare_app ccare_prod
```

Grant the application role only what the application needs. `CREATEDB` is needed
only if the Django test suite runs on the production host, which it should not;
omit it there. The backup role needs `pg_read_all_data` or an equivalent plus
`CONNECT`, and should not be the application role.

```
sudo -u postgres psql -c "GRANT CONNECT ON DATABASE ccare_prod TO ccare_app"
```

Set a password interactively for each role; never place it in a shell command
that reaches shell history or process listings.

## 5. Install dependencies

```
cd /opt/ccare/app
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements/production.txt
.venv/bin/pip check
```

`requirements/production.txt` contains only the base runtime. Development and
test tooling is never installed on a production host.

## 6. Apply migrations

```
.venv/bin/python manage.py migrate --noinput
.venv/bin/python manage.py showmigrations
```

Every migration must show `[X]`. Take a backup **before** migrating so the
schema change can be rolled back with the matching data state. Migrations are
append-only in this repository: never edit an applied migration.

## 7. Collect static files

```
.venv/bin/python manage.py collectstatic --noinput
```

This writes `STATIC_ROOT` (`/opt/ccare/app/staticfiles`). The application server
must **not** serve static or media files in production; the reverse proxy does.
The application currently defines no upload-bearing models, so `MEDIA_ROOT` is
empty by design, but the proxy rule must still exist before the first upload
capability is added.

## 8. Create the initial administrator and configuration

```
.venv/bin/python manage.py createsuperuser
.venv/bin/python manage.py seed_demo_data --confirm-development   # optional, local demos only
```

`seed_demo_data` refuses to run against a production database. It exists for
training and demonstrations; never use it on a customer database.

Then configure, through the application UI at `/settings/`:

1. Brand, company, region, service center and department hierarchy.
2. Catalog brand, category, product model, variant and identification policy.
3. Service taxonomy: complaint symptoms, fault diagnoses, repair actions and
   the quality-control checklist.
4. Role permissions and organizational assignments for every persona.
5. One part category and spare part with compatibility mapped to the model.
6. Inventory locations and their type.
7. SLA policy and communication templates.

Verify with the read-only readiness command, which never calls a provider and
never creates data:

```
.venv/bin/python manage.py check_installation_readiness
.venv/bin/python manage.py check_installation_readiness --json
```

It exits non-zero while any required item is missing.

## 9. Configure the application server

Any WSGI server is acceptable. The project exposes `config.wsgi:application`.
Run it as the `ccare` service account, bind to a loopback port, and let the
reverse proxy own the public socket.

Key requirements:

- **Run as many workers as the host can sustain.** PostgreSQL connection count
  must cover `workers × threads` plus the backup and monitoring sessions.
- Set `umask 027` so collected static files and logs are not world-readable.
- Set a request timeout and a maximum request body size at the proxy.
- Do **not** pass `--insecure` or run the development server.
- If using a prefork server, never run `collectstatic` or `migrate` from the
  same process pool that serves traffic.

Example systemd unit (adapt paths and user):

```
[Unit]
Description=C-CARE application
After=network-online.target postgresql.service

[Service]
User=ccare
WorkingDirectory=/opt/ccare/app
EnvironmentFile=/etc/ccare/ccare.env
ExecStart=/opt/ccare/app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3
Restart=on-failure
RestartSec=5
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ReadWritePaths=/opt/ccare/app/staticfiles /opt/ccare/app/media

[Install]
WantedBy=multi-user.target
```

`gunicorn` is not a declared project dependency; install it explicitly on the
host if you choose it, or use the WSGI server of your operating system.

## 10. Configure the reverse proxy and TLS

The proxy must:

1. Terminate TLS with a certificate from a trusted authority and redirect
   HTTP to HTTPS.
2. Serve `/static/` from `STATIC_ROOT` and `/media/` from `MEDIA_ROOT`, with
   long cache lifetimes and correct content types.
3. Proxy everything else to the application socket.
4. Set `X-Forwarded-Proto: https` on the proxied request.
5. **Strip inbound `X-Forwarded-Proto`, `X-Forwarded-Protocol` and
   `X-Forwarded-SSL` from the client** before setting its own value. Otherwise
   a client can forge the scheme.
6. Set a request body size limit and a read timeout.
7. Serve `/health/` and `/ready/` without authentication, and do not log their
   bodies.

Because C-CARE sets `SECURE_SSL_REDIRECT=True`, a mismatch between step 4 and
`DJANGO_TRUSTED_PROXY_HEADER` causes a redirect loop. The two must agree: set
the variable to `X-FORWARDED-PROTO` (or the header your proxy actually sets)
when the proxy terminates TLS.

Recommended nginx location blocks:

```
location /static/ { alias /opt/ccare/app/staticfiles/; expires 30d; add_header Cache-Control "public"; }
location /media/  { alias /opt/ccare/app/media/; expires 7d;  add_header Cache-Control "public"; }
location /health/ { proxy_pass http://127.0.0.1:8000; access_log off; }
location /ready/  { proxy_pass http://127.0.0.1:8000; access_log off; }
location /        { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host;
                    proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; }
```

## 11. Verify health after start

```
curl -fsS https://<host>/health/    # {"status":"ok"}       liveness, no database
curl -fsS https://<host>/ready/     # {"status":"ready"}    readiness, bounded database check
```

`/health/` answers only if the process is running. `/ready/` additionally
performs one bounded read inside a transaction-local statement timeout and
returns HTTP 503 with `{"status":"not_ready"}` when the database is
unreachable. Neither endpoint discloses a version, hostname, database name or
exception. Point the load balancer's health check at `/ready/` and the
process supervisor's at `/health/`.

## 12. Smoke tests

1. Sign in as a non-staff persona with a scoped role assignment.
2. Confirm the operational dashboard renders and shows only authorized records.
3. Create a customer-owned device and confirm it appears for that customer.
4. Book an appointment, check in, create a service case and reach `RECEIVED`.
5. Confirm an unauthorized persona sees HTTP 403 for another company's case and
   HTTP 404 for a record outside its scope.
6. Confirm a POST without a CSRF token is rejected with HTTP 403.
7. Confirm static assets return HTTP 200 from the proxy path.
8. Confirm `/admin/` rejects a non-staff user.

## 13. Enable production communications

Delivery stays disabled until every step is complete:

1. Set `COMMUNICATIONS_ALLOW_EXTERNAL=True`.
2. Set `COMMUNICATIONS_EMAIL_BACKEND=apps.communications.providers.DjangoEmailProvider`.
3. Set `COMMUNICATIONS_SMS_BACKEND` only once a production SMS adapter exists.
   **RC2 ships no SMS adapter; SMS delivery must remain unconfigured.** An
   unconfigured channel in production raises `ImproperlyConfigured` rather than
   silently succeeding.
4. Set the SMTP variables. TLS and SSL are mutually exclusive; either both
   `EMAIL_HOST_USER` and `EMAIL_HOST_PASSWORD` are set or neither is.
5. Send a test notification to a controlled internal address and confirm the
   attempt is recorded with a definite outcome.

## 14. Verify backup and restore

Follow `OPERATIONS_RUNBOOK.md` §Backup and §Restore. Before declaring the
release live, prove that a fresh `pg_dump` restores into a scratch database and
that the restored copy answers `manage.py check` and the readiness probe.
Confirm the backup exists **off-host**.

## 15. Rollback

Rollback is a deployment decision, not a code path. In order of preference:

1. **Configuration rollback.** Restore the previous environment file and restart.
   Safe, fast, and sufficient for most incidents.
2. **Application rollback.** Check out the previous release commit and restart.
   Safe only when that release is compatible with the current schema.
3. **Database rollback.** Restore the pre-deployment backup into a fresh
   database, correct the connection setting, and restart. This **discards
   business data written after the backup**; it is a last resort and requires an
   explicit decision by the operator.

Before every deployment, record the current commit, confirm a recent backup
exists, and confirm the backup timestamp is newer than the last migration. A
rollback that needs a migration downgrade is out of scope for this release:
C-CARE has no down-migration path, which is why the backup comes first.

## 16. Release

C-CARE v1.0.0 Release Candidate 2 still requires a fresh full release audit.
`RELEASE_CANDIDATE_AUDIT.md` records the current gate and remediation evidence;
`RELEASE_CHECKLIST.md` retains the historical RC1 checks and operator obligations.
Do not treat RC1 approval as RC2 approval. Do not tag, push or announce from a
deployment host.