# Deployment Steps — Beginner Runbook

This is the step-by-step "what to do, in what order" guide for getting the courseware generator from your laptop into production.

The technical "why" of each decision is in [DEPLOYMENT_PLAN.md](DEPLOYMENT_PLAN.md). This file is just **what to type, in what order, and how to check it worked**.

---

## What you'll have at the end

```
A server somewhere (Linux or Windows VM) that:
  - Has the app running 24/7
  - Asks users to log in before they can use it
  - Stores user accounts, history, token usage in a small database
  - Pulls the big asset files (banks, images) from Amazon S3
  - Uploads generated PowerPoint files to Amazon S3
  - Gets new code via uploaded zip files (no GitHub needed)
```

Everyone on the team gets a URL, logs in with their own account, and generates decks. Generated files survive server restarts. You can roll back to any prior version of the code by re-running one command.

---

## Glossary (so we don't lose anyone)

| Term | Plain meaning |
|---|---|
| **S3** | Amazon's cloud file storage. Think of it like Dropbox you control via code. You pay ~pennies per GB per month. |
| **bucket** | A folder-like container in S3. One bucket per project is normal. |
| **IAM** | "Identity and Access Management" — Amazon's way of saying who can do what. |
| **EC2** | An Amazon virtual machine (a computer in the cloud you rent by the hour). |
| **SQLite** | A database that's just a single file on disk. No server, no setup. |
| **environment variable** | A piece of config that lives outside your code (like the API key). Set with `$env:VAR=value` on Windows or `export VAR=value` on Linux. |
| **bcrypt** | A password-hashing library. Stores passwords safely so even you can't read them. |
| **presigned URL** | A temporary download link to an S3 file that expires after an hour. |
| **virtual environment (venv)** | A self-contained Python installation, so this app's packages don't conflict with others on the same machine. |
| **systemd / Windows Service** | A way to make a program start automatically when the machine boots and restart if it crashes. |

---

## Phases at a glance

| Phase | What it does | Time |
|---|---|---|
| 0 | Confirm your local app works | 30 min |
| 1 | Move state from JSON files to SQLite | 1 day |
| 2 | Add login screen | 1 day |
| 3a | Put banks (PPTs, images) in S3 | half day |
| 3b | Put generated outputs in S3 | half day |
| 4 | Set up the actual server | 1 day |
| 5 | Set up the no-GitHub deploy workflow | half day |
| 6 | Run it day-to-day | ongoing |

**Total work: about a week, spread across multiple sessions.** You don't have to do all phases at once. Each phase is independently useful and shippable.

---

## Prerequisites (do these before Phase 1)

- [ ] Python 3.11 or newer installed on your laptop. Check with `python --version`.
- [ ] An AWS account. Sign up at https://aws.amazon.com (you'll need a credit card; first 12 months have a free tier).
- [ ] A server to deploy to. Three options:
  - Amazon EC2 (rented from AWS, ~$10–$30/month for a small instance)
  - DigitalOcean / Linode / Hetzner droplet (similar pricing, often simpler)
  - A spare Windows or Linux machine on the company network
- [ ] Your local app currently runs. If `python -m web.app` doesn't start the dashboard, fix that first.

---

# Phase 0: Confirm baseline

**Goal:** Make sure your local app works exactly as expected before you start changing things. So if something breaks later, you know it was your change.

### Steps

1. Open PowerShell, go to the project folder:
   ```powershell
   cd C:\Users\admin\Desktop\v2
   ```

2. Run the server:
   ```powershell
   python -m web.app
   ```

3. Open `http://127.0.0.1:5000` in your browser. The dashboard should load.

4. Generate a small course (1 day, 20 slides). Wait for it to finish. Download the PPTX.

5. Stop the server (Ctrl+C in PowerShell).

### How you know it worked
- The PPTX you downloaded opens in PowerPoint and looks normal.
- No errors in the PowerShell window.

### If something's broken
Don't proceed. Fix it first. Common issues:
- Missing `.env` file → make sure `ANTHROPIC_API_KEY` is set
- Missing banks → make sure `tagged infographics/`, `drill_down_bank/`, `images/` folders are populated
- `pip install -r requirements.txt` if dependencies are missing

---

# Phase 1: Database (replace JSON files with SQLite)

**Goal:** Right now the app stores history, activity, and token usage in three JSON files. We'll move them to a real database (SQLite — a single file on disk, no setup). This unblocks the next phase (logins).

**Result for the user:** Nothing visible changes. Dashboard looks identical.

### Steps

1. **Add the new Python packages** to `requirements.txt`:
   ```
   Flask-SQLAlchemy>=3.1.0
   bcrypt>=4.0.0
   ```
   (bcrypt is for Phase 2; adding it now means you only run `pip install` once.)

2. **Install them:**
   ```powershell
   pip install -r requirements.txt
   ```

3. **Create `web/db.py`** — sets up the database connection:
   ```python
   # web/db.py
   import os
   from sqlalchemy import create_engine
   from sqlalchemy.orm import sessionmaker, declarative_base

   DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./courseware.db")
   engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
   SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
   Base = declarative_base()
   ```

4. **Create `web/models.py`** — defines the tables. Copy the schema from [DEPLOYMENT_PLAN.md](DEPLOYMENT_PLAN.md) "Schema" section.

5. **Create `scripts/init_db.py`** — runs once to create the tables and import existing JSON data:
   ```python
   # scripts/init_db.py
   from web.db import engine, Base, SessionLocal
   from web import models  # noqa: F401, ensures models are registered
   import json
   from pathlib import Path
   from datetime import datetime

   Base.metadata.create_all(engine)
   print("Tables created.")

   data_dir = Path(__file__).parent.parent / "web" / "data"
   db = SessionLocal()
   # Import history.json -> jobs table
   # Import activity.json -> daily_activity table
   # Import tokens.json -> token_usage table
   # (Fill in based on actual JSON shapes — see DEPLOYMENT_PLAN.md Sprint 1 tasks)
   db.commit()
   print("Migration complete.")
   ```

6. **Modify `web/app.py`** — replace JSON reads/writes with DB queries. Touchpoints (line numbers approximate):
   - `/api/history` route → query the `jobs` table
   - `/api/stats` route → query `daily_activity`
   - `/api/generate` → insert a `Job` row at start, update on completion
   - The activity bumping logic → use `INSERT OR UPDATE` on `daily_activity`

7. **Modify `ppt_generator/engine/token_tracker.py`** — the function that currently writes to `tokens.json` should instead insert into the `token_usage` table.

8. **Run the migration:**
   ```powershell
   python scripts/init_db.py
   ```
   You should see `Tables created.` and `Migration complete.` plus row counts.

9. **Move the old JSON files to a backup folder** (don't delete yet):
   ```powershell
   New-Item -Type Directory -Force web\data\_legacy_json
   Move-Item web\data\history.json    web\data\_legacy_json\
   Move-Item web\data\activity.json   web\data\_legacy_json\
   Move-Item web\data\tokens.json     web\data\_legacy_json\
   ```

10. **Start the server and test:**
    ```powershell
    python -m web.app
    ```
    Open the dashboard. History should show the old data. Generate a new course; confirm:
    - The new job appears in the history list
    - `web\data\history.json` is NOT recreated (only the DB is being written to)
    - File `courseware.db` exists at the project root

### How you know it worked
- Dashboard looks identical to before.
- File `courseware.db` exists.
- Old history is visible. New generations land in the DB, not in JSON files.

### If something breaks
- Restore the JSON files from `web\data\_legacy_json\` back to `web\data\`.
- Revert your code changes (`git checkout -- web/app.py ppt_generator/engine/token_tracker.py`).
- Old behavior is back. Investigate the error in `scripts/init_db.py` output or the server console.

---

# Phase 2: Login screen (auth)

**Goal:** Add a login page. Only logged-in users can see the dashboard or trigger builds.

**Result for the user:** They see a login page first. After logging in, dashboard works as before. Admin (you) can create user accounts.

### Steps

1. **Generate a session secret** (a long random string Flask uses to sign cookies):
   ```powershell
   python -c "import secrets; print(secrets.token_hex(32))"
   ```
   Copy the output.

2. **Add to your `.env`:**
   ```
   SESSION_SECRET=<paste the output here>
   INITIAL_ADMIN_EMAIL=<your email>
   INITIAL_ADMIN_PASSWORD=<a temporary password — you'll change it on first login>
   ```

3. **Create `web/auth.py`** — handles login, logout, the `@login_required` decorator. Boilerplate is in [DEPLOYMENT_PLAN.md](DEPLOYMENT_PLAN.md) "Sprint 2" section.

4. **Create `web/templates/login.html`** — a simple form with email + password fields, posts to `/login`.

5. **Add password hashing methods** to the `User` model in `web/models.py`:
   ```python
   import bcrypt
   def set_password(self, plain):
       self.password_hash = bcrypt.hashpw(plain.encode(), bcrypt.gensalt()).decode()
   def check_password(self, plain):
       return bcrypt.checkpw(plain.encode(), self.password_hash.encode())
   ```

6. **Modify `web/app.py`:**
   - Set `app.secret_key = os.environ["SESSION_SECRET"]`
   - Set session lifetime to 8 hours (so users don't get logged out during a 10-minute build)
   - Register the auth blueprint
   - Add `@login_required` to every `/api/*` route except `/login` and `/logout`
   - In `/api/generate`, set `Job.user_id = g.user.id`

7. **Create `scripts/create_admin.py`:**
   ```python
   # scripts/create_admin.py
   import os
   from web.db import SessionLocal
   from web.models import User

   email = os.environ.get("INITIAL_ADMIN_EMAIL")
   password = os.environ.get("INITIAL_ADMIN_PASSWORD")
   if not email or not password:
       email = input("Admin email: ")
       password = input("Admin password: ")
       name = input("Admin name: ")
   else:
       name = "Admin"

   db = SessionLocal()
   if db.query(User).filter_by(email=email).first():
       print(f"User {email} already exists.")
   else:
       u = User(email=email, name=name, is_admin=True)
       u.set_password(password)
       db.add(u)
       db.commit()
       print(f"Created admin {email}.")
   ```

8. **Run it to create yourself as admin:**
   ```powershell
   python scripts/create_admin.py
   ```

9. **Update `web/static/js/app.js`:**
   - Wrap every `fetch('/api/...')` call so that a 401 response redirects to `/login`
   - On boot, call `GET /api/me` to get the logged-in user, display their name in the header

10. **Update `web/templates/index.html`** — add a header element showing the logged-in user name and a Logout button.

11. **Test:**
    ```powershell
    python -m web.app
    ```
    - Open dashboard while logged out → should redirect to `/login`.
    - Log in with your admin credentials → dashboard loads.
    - Click logout → back to login page.

### How you know it worked
- Dashboard requires login.
- You can log in and out.
- Generating a course still works.
- A non-logged-in `curl http://127.0.0.1:5000/api/history` returns 401.

### Common gotchas
- **Session secret missing in `.env`** → server crashes at startup. Fix: regenerate one and add to `.env`.
- **CSRF errors on POST** → install `Flask-WTF` and add CSRF tokens to your forms, OR check Origin/Referer headers manually.
- **Logged out mid-build** → session is too short. Make sure `app.permanent_session_lifetime = timedelta(hours=8)` is set.

---

# Phase 3a: Move banks (PPT files, images) to S3

**Goal:** Right now the bank files live on your laptop. The production server needs them. We'll put them in S3 once, then the server fetches them at boot.

**Result for the user:** Nothing changes for them.

### Steps

1. **Create an AWS account** if you don't have one: https://aws.amazon.com

2. **Create an S3 bucket** in the AWS console:
   - Go to S3 → Create bucket
   - Bucket name: something globally unique, e.g. `knowledge-courseware-prod-2026`
   - Region: pick the one closest to your team (e.g. `eu-west-2` for London)
   - Leave defaults otherwise. **Block all public access** (yes, keep this on).
   - Click Create.

3. **Create an IAM user** that the app will use:
   - AWS console → IAM → Users → Create user
   - Username: `courseware-app`
   - Access type: "Programmatic access" (NOT console access)
   - Permissions: attach a custom policy granting access ONLY to your bucket:
     ```json
     {
       "Version": "2012-10-17",
       "Statement": [{
         "Effect": "Allow",
         "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
         "Resource": [
           "arn:aws:s3:::knowledge-courseware-prod-2026",
           "arn:aws:s3:::knowledge-courseware-prod-2026/*"
         ]
       }]
     }
     ```
   - Finish creating. **Save the Access Key ID and Secret Access Key** — you only see the secret once.

4. **Install boto3 (the AWS Python SDK):**
   - Add `boto3>=1.34.0` to `requirements.txt`
   - Run `pip install -r requirements.txt`

5. **Add AWS credentials to your `.env`:**
   ```
   S3_BUCKET=knowledge-courseware-prod-2026
   AWS_REGION=eu-west-2
   AWS_ACCESS_KEY_ID=<the access key from step 3>
   AWS_SECRET_ACCESS_KEY=<the secret key from step 3>
   S3_BANKS_PREFIX=banks/
   S3_OUTPUTS_PREFIX=outputs/
   ```

6. **Create `scripts/upload_banks_to_s3.py`:**
   ```python
   # scripts/upload_banks_to_s3.py
   import os
   from pathlib import Path
   import boto3

   ROOT = Path(__file__).parent.parent
   BUCKET = os.environ["S3_BUCKET"]
   PREFIX = os.environ.get("S3_BANKS_PREFIX", "banks/")

   s3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION"))

   upload_dirs = {
       "tagged_infographics": ROOT / "tagged infographics",
       "drill_down_cards":    ROOT / "drill_down_bank",      # only .pptx files
       "sample_templates":    ROOT / "sample template",
       "images":              ROOT / "images",
   }

   uploaded = 0
   total_bytes = 0
   for s3_subdir, local_dir in upload_dirs.items():
       if not local_dir.exists():
           continue
       for f in local_dir.rglob("*"):
           if not f.is_file():
               continue
           if f.suffix in (".py",):  # skip _survey.py etc.
               continue
           rel = f.relative_to(local_dir)
           key = f"{PREFIX}{s3_subdir}/{rel.as_posix()}"
           s3.upload_file(str(f), BUCKET, key)
           uploaded += 1
           total_bytes += f.stat().st_size
           print(f"  {key}")
   print(f"\nUploaded {uploaded} files, {total_bytes/1024/1024:.1f} MB")
   ```

7. **Run it (this is a one-time operation):**
   ```powershell
   python scripts/upload_banks_to_s3.py
   ```
   This will take a few minutes (uploading ~100 MB).

8. **Verify in AWS console:**
   - Go to your bucket
   - You should see folders: `banks/tagged_infographics/`, `banks/drill_down_cards/`, `banks/sample_templates/`, `banks/images/`

### How you know it worked
- AWS console shows the bank files under your bucket
- `aws s3 ls s3://your-bucket/banks/` from the command line lists them

### Common gotchas
- **"Access Denied" error** → your IAM policy is wrong. Re-check that the bucket name in the policy exactly matches.
- **"Could not connect"** → wrong region. Check `AWS_REGION` matches the bucket's region.
- **Very slow upload** → normal for the first run. Subsequent runs (with a sync-only flag) would be faster, but the first time you're uploading everything.

---

# Phase 3b: Generated outputs to S3

**Goal:** When the pipeline finishes, the generated PPTX gets uploaded to S3 instead of staying on the server's disk. Downloads happen via temporary signed URLs.

**Result for the user:** Nothing visible changes. They still click "Download" and get the file.

### Steps

1. **Create `web/s3.py`** — small helper module:
   ```python
   # web/s3.py
   import os
   import boto3

   _client = None
   def client():
       global _client
       if _client is None:
           _client = boto3.client("s3", region_name=os.environ.get("AWS_REGION"))
       return _client

   def upload_pptx(local_path, job_id):
       bucket = os.environ["S3_BUCKET"]
       prefix = os.environ.get("S3_OUTPUTS_PREFIX", "outputs/")
       key = f"{prefix}{job_id}.pptx"
       client().upload_file(local_path, bucket, key)
       return key

   def presigned_get_url(key, ttl=3600):
       bucket = os.environ["S3_BUCKET"]
       return client().generate_presigned_url(
           "get_object",
           Params={"Bucket": bucket, "Key": key},
           ExpiresIn=ttl,
       )
   ```

2. **Modify `web/app.py`:**
   - In the pipeline-completion handler: after the build saves the PPTX to `tmp_outputs/<job_id>.pptx`, call `web.s3.upload_pptx(path, job_id)` and store the returned key in `Job.output_s3_key`.
   - Then delete the local copy: `Path(path).unlink()`.
   - In the `/api/download/<id>` endpoint: look up the Job's `output_s3_key`, call `presigned_get_url`, return the URL (either as a JSON `{"url": "..."}` or as a 302 redirect).

3. **Update `web/static/js/app.js`** — the download click handler. If the endpoint now returns a JSON URL, the JS should `window.location = response.url`.

4. **Test:**
   ```powershell
   python -m web.app
   ```
   Generate a course. After completion, check:
   - The PPTX appears in `s3://your-bucket/outputs/<job_id>.pptx`
   - The local `tmp_outputs/` folder is empty (file was deleted after upload)
   - Clicking Download in the dashboard still downloads the file

### How you know it worked
- New jobs land in S3 under `outputs/`
- Local disk doesn't fill up with old PPTXs
- Download still works

---

# Phase 4: Set up the production server

**Goal:** Get the app running on a server that's online 24/7.

### Sub-step 4.0: Choose your server

| Option | Pros | Cons | Cost |
|---|---|---|---|
| AWS EC2 (Ubuntu) | Same cloud as S3 (fast), full control | You manage Linux | ~$10/mo for `t3.small` |
| DigitalOcean droplet | Simpler dashboard than AWS | Different bills | ~$12/mo |
| Company Windows server | No cloud bill, on internal network | Need IT cooperation | included |

For a small team, **AWS EC2 Ubuntu 22.04** is the typical pick (same cloud as your S3, so transfers are free and fast). Steps below assume Linux. For Windows, the commands change but the structure is the same.

### Sub-step 4.1: Provision the server

1. Launch a t3.small EC2 instance, Ubuntu 22.04, in the same region as your S3 bucket
2. Assign it an IAM role with the same S3 permissions you gave the IAM user (so you don't need AWS keys on the server)
3. Open security group to allow port 5000 from your team's IP range (or all of `0.0.0.0/0` if behind a VPN — never expose to the open internet without auth in front)
4. SSH into it: `ssh ubuntu@<server-ip>`

### Sub-step 4.2: Install dependencies

```bash
sudo apt update
sudo apt install -y python3.11 python3.11-venv python3-pip git unzip
```

### Sub-step 4.3: Create directory structure

```bash
sudo mkdir -p /opt/courseware/code
sudo mkdir -p /var/lib/courseware/banks
sudo mkdir -p /var/lib/courseware/tmp_outputs
sudo mkdir -p /var/lib/courseware/uploads/templates
sudo mkdir -p /var/lib/courseware/uploads/outlines
sudo chown -R ubuntu:ubuntu /opt/courseware /var/lib/courseware
```

### Sub-step 4.4: Get the code onto the server

For now (before Phase 5), simplest: zip the code on your laptop and SCP it over.

On your laptop:
```powershell
Compress-Archive -Path ppt_generator,web,scripts,tagged_bank,drill_down_bank,requirements.txt -DestinationPath release.zip
scp release.zip ubuntu@<server-ip>:/tmp/
```

On the server:
```bash
cd /opt/courseware/code
unzip /tmp/release.zip
```

### Sub-step 4.5: Set up Python environment

```bash
cd /opt/courseware/code
python3.11 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

### Sub-step 4.6: Provision `.env`

```bash
nano /opt/courseware/.env
```

Paste in your env vars (everything from your local `.env` PLUS the new server-specific paths):
```
ANTHROPIC_API_KEY=sk-ant-...
SESSION_SECRET=<from phase 2>
DATABASE_URL=sqlite:////var/lib/courseware/courseware.db
S3_BUCKET=knowledge-courseware-prod-2026
AWS_REGION=eu-west-2
S3_BANKS_PREFIX=banks/
S3_OUTPUTS_PREFIX=outputs/
BANKS_ROOT=/var/lib/courseware/banks
TAGGED_BANK_DIR=/var/lib/courseware/banks/tagged_infographics
DRILL_DOWN_BANK_DIR=/var/lib/courseware/banks/drill_down_cards
SAMPLE_TEMPLATES_DIR=/var/lib/courseware/banks/sample_templates
ILLUSTRATIONS_DIR=/var/lib/courseware/banks/images
TMP_OUTPUTS_DIR=/var/lib/courseware/tmp_outputs
UPLOADS_DIR=/var/lib/courseware/uploads
```

(Notice: if you used an IAM role on EC2, you DON'T need `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` — boto3 picks up the role automatically.)

### Sub-step 4.7: Sync banks from S3

```bash
cd /opt/courseware/code
source .venv/bin/activate
set -a; source /opt/courseware/.env; set +a    # load env vars
python scripts/sync_banks_from_s3.py
```

This downloads ~100 MB. Takes a few minutes the first time.

### Sub-step 4.8: Initialize the database

```bash
python scripts/init_db.py
python scripts/create_admin.py
```

### Sub-step 4.9: Test-run the app

```bash
python -m web.app
```

From your laptop, open `http://<server-ip>:5000`. You should see the login page. Log in with your admin credentials. Generate a small course end-to-end.

Press Ctrl+C to stop the server.

### Sub-step 4.10: Make it run 24/7 as a service

Create `/etc/systemd/system/courseware.service`:
```ini
[Unit]
Description=Courseware Generator
After=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/courseware/code
EnvironmentFile=/opt/courseware/.env
ExecStart=/opt/courseware/code/.venv/bin/python -m web.app
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Then:
```bash
sudo systemctl daemon-reload
sudo systemctl enable courseware
sudo systemctl start courseware
sudo systemctl status courseware
```

You should see green "active (running)" status.

### How you know Phase 4 worked
- `systemctl status courseware` shows running
- `curl http://<server-ip>:5000/login` returns the login HTML
- Reboot the server (`sudo reboot`); after it comes back, the service is still running

### Common gotchas
- **Permission errors on `/var/lib/courseware`** → run the `chown` in sub-step 4.3 again
- **Service won't start** → `sudo journalctl -u courseware -n 50` shows the error
- **boto3 "Unable to locate credentials"** → IAM role isn't attached. Either attach one in EC2 console or put `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` in `.env`

---

# Phase 5: No-GitHub deploy workflow

**Goal:** Now that the server runs, you need a way to ship new code. Instead of pushing to GitHub, you upload zips to S3.

### Steps

1. **Create `scripts/pack_release.py`** on your laptop:
   ```python
   # scripts/pack_release.py
   import os
   import zipfile
   import subprocess
   from datetime import datetime
   from pathlib import Path
   import boto3

   ROOT = Path(__file__).parent.parent
   EXCLUDE = {
       ".git", "__pycache__", ".venv", ".claude",
       "images", "tagged infographics", "drill_down_bank",  # banks go separately
       "sample template", "web/uploads", "web/data/_legacy_json",
       "tmp_outputs", "courseware.db",
   }

   # Require a clean git tree
   status = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True, cwd=ROOT)
   if status.stdout.strip():
       print("ERROR: working tree is dirty. Commit your changes first.")
       exit(1)

   commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True, cwd=ROOT).stdout.strip()
   date = datetime.now().strftime("%Y-%m-%d")
   name = f"release-{date}-{commit}.zip"
   zip_path = ROOT / name

   def should_exclude(path: Path) -> bool:
       rel = path.relative_to(ROOT)
       return any(str(rel).startswith(e) for e in EXCLUDE)

   with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
       for p in ROOT.rglob("*"):
           if p.is_file() and not should_exclude(p):
               zf.write(p, p.relative_to(ROOT))
   print(f"Wrote {zip_path}")

   # Upload to S3 (only when called with --upload flag, to allow testing locally)
   bucket = os.environ["S3_BUCKET"]
   s3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION"))
   s3.upload_file(str(zip_path), bucket, f"releases/{name}")
   s3.copy_object(Bucket=bucket, Key="releases/latest.zip", CopySource=f"{bucket}/releases/{name}")
   print(f"Uploaded to s3://{bucket}/releases/{name}")
   print(f"Updated s3://{bucket}/releases/latest.zip")
   ```

2. **Create `scripts/backup_git_bundle.py`** — your laptop-death insurance:
   ```python
   # scripts/backup_git_bundle.py
   import os
   import subprocess
   from datetime import datetime
   from pathlib import Path
   import boto3

   ROOT = Path(__file__).parent.parent
   date = datetime.now().strftime("%Y-%m-%d")
   bundle = ROOT / f"backup-{date}.bundle"

   subprocess.run(["git", "bundle", "create", str(bundle), "--all"], cwd=ROOT, check=True)

   bucket = os.environ["S3_BUCKET"]
   s3 = boto3.client("s3", region_name=os.environ.get("AWS_REGION"))
   s3.upload_file(str(bundle), bucket, f"backups/{bundle.name}")
   bundle.unlink()
   print(f"Backed up to s3://{bucket}/backups/{bundle.name}")
   ```

3. **Create `/opt/courseware/scripts/deploy_release.sh`** on the server:
   ```bash
   #!/bin/bash
   set -e
   set -a; source /opt/courseware/.env; set +a
   VERSION=${1:-latest.zip}
   echo "Deploying $VERSION..."
   cd /tmp
   aws s3 cp "s3://$S3_BUCKET/releases/$VERSION" ./release.zip
   rm -rf /opt/courseware/code_new
   mkdir /opt/courseware/code_new
   cd /opt/courseware/code_new
   unzip -q /tmp/release.zip
   /opt/courseware/code/.venv/bin/pip install -r requirements.txt
   # Swap directories
   mv /opt/courseware/code /opt/courseware/code_old
   mv /opt/courseware/code_new /opt/courseware/code
   mv /opt/courseware/code_old/.venv /opt/courseware/code/.venv
   rm -rf /opt/courseware/code_old
   sudo systemctl restart courseware
   echo "Deployed."
   ```
   Make it executable: `chmod +x deploy_release.sh`

4. **Set up automatic backups (laptop side):** Open Task Scheduler on Windows → Create Basic Task. Trigger: Weekly, Friday 5 PM. Action: run `python scripts/backup_git_bundle.py` from the project directory with the env vars loaded.

5. **Your first end-to-end release:**
   - On laptop: `git commit -am "small change"` then `python scripts/pack_release.py`
   - SSH to server: `bash /opt/courseware/scripts/deploy_release.sh`
   - Verify dashboard reflects the change

### How you know Phase 5 worked
- `s3://your-bucket/releases/latest.zip` exists
- `s3://your-bucket/backups/*.bundle` has at least one bundle
- Running `deploy_release.sh` on the server pulls + restarts and the service comes back healthy

### Rollback workflow
If a release breaks something:
```bash
bash /opt/courseware/scripts/deploy_release.sh release-2026-05-20-bccc1f3.zip
```
(That is: deploy the previous version's zip by name.)

---

# Phase 6: Day-to-day operations

### When you change code
1. Edit files locally
2. `git commit -am "what you changed"`
3. `python scripts/pack_release.py` (uploads to S3)
4. SSH to server
5. `bash /opt/courseware/scripts/deploy_release.sh`
6. Verify in browser

### When you add new illustrations or banks
1. Drop the new files into the right local folders
2. `python scripts/upload_banks_to_s3.py`
3. SSH to server
4. `python /opt/courseware/code/scripts/sync_banks_from_s3.py`
5. (Optional) Restart the service: `sudo systemctl restart courseware`

### When you create a new user
1. SSH to server
2. `cd /opt/courseware/code && source .venv/bin/activate`
3. `set -a; source /opt/courseware/.env; set +a`
4. `python -c "from web.db import SessionLocal; from web.models import User; db=SessionLocal(); u=User(email='teammate@theknowledgeacademy.com', name='Their Name'); u.set_password('temp-password'); db.add(u); db.commit()"`
5. Tell them the temp password; they change it on first login

(Or build a UI for this and skip the SSH dance — it's already in the Phase 2 spec.)

### When you want to see what's happening
- Logs: `sudo journalctl -u courseware -f` (follow live)
- Token usage: query the `token_usage` table in the DB
- Generated files: list `s3://your-bucket/outputs/`

### When something breaks
1. `sudo systemctl status courseware` — is the service running?
2. `sudo journalctl -u courseware -n 100` — see the last 100 log lines
3. If a recent release caused it: roll back (see Phase 5)
4. If it's a hung build: restart the service

---

# Troubleshooting cheat sheet

| Symptom | Likely cause | Fix |
|---|---|---|
| Server won't start | `.env` missing a required var | Check `journalctl`, add missing var |
| 401 on every request | Session secret changed | Don't change `SESSION_SECRET` after deploy; existing sessions invalidate |
| "Access Denied" from S3 | IAM permissions or bucket name typo | Re-check IAM policy resource ARNs |
| Build crashes "bank file not found" | Banks didn't sync | Run `sync_banks_from_s3.py` manually |
| Build crashes "no API key" | `.env` missing `ANTHROPIC_API_KEY` | Add and restart service |
| Downloaded PPTX is corrupted | Built older format / wrong extension | Open server log, check build error |
| Dashboard slow to load | DB query inefficient or many history rows | Add `LIMIT 100` to history query |
| User session expires mid-build | Session lifetime too short | Bump `permanent_session_lifetime` to 8 hours |

---

# Sanity checks before you call it "in production"

- [ ] All env vars set on server (`grep -c = /opt/courseware/.env` ≥ 12)
- [ ] Service starts on reboot (`sudo systemctl is-enabled courseware` → enabled)
- [ ] Login works
- [ ] You can create a new user via admin endpoint or script
- [ ] End-to-end build completes and PPTX downloads correctly
- [ ] Generated PPTX appears in S3, not on local disk
- [ ] Logs are flowing to journald (`journalctl -u courseware --since "1 hour ago"` shows entries)
- [ ] At least one weekly backup bundle exists in `s3://your-bucket/backups/`
- [ ] At least one release zip exists in `s3://your-bucket/releases/`
- [ ] Token usage is being recorded (query `token_usage` table)
- [ ] Daily activity is being recorded (query `daily_activity` table)

When all 11 are green: production.

---

# Reference

- Architectural details and rationale → [DEPLOYMENT_PLAN.md](DEPLOYMENT_PLAN.md)
- Code change touchpoints (file:line) → also in DEPLOYMENT_PLAN.md, "Sprint" sections
- Production folder requirements → covered in chat history (the folder-by-folder verdict)
