Deployment Guide — Nginx + AWS EC2 + PostgreSQL

Target: Ubuntu 22.04 LTS on AWS EC2. App served by Gunicorn behind Nginx (TLS termination).

1. System packages

sudo apt update
sudo apt install -y python3.13 python3.13-venv python3-pip nginx postgresql

2. PostgreSQL

sudo -u postgres psql <<'SQL'
CREATE USER iam_user WITH PASSWORD 'CHANGE_ME_STRONG';
CREATE DATABASE iam OWNER iam_user;
SQL

DATABASE_URL=postgresql+psycopg2://iam_user:CHANGE_ME_STRONG@localhost:5432/iam

3. Application

sudo mkdir -p /opt/sims && sudo chown $USER /opt/sims
git clone <repo> /opt/sims/app && cd /opt/sims/app
python3.13 -m venv /opt/sims/venv
/opt/sims/venv/bin/pip install -r requirements.txt gunicorn

Create /opt/sims/app/.env (never commit it):

APP_ENV=production
SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))")
JWT_SECRET_KEY=$(python -c "import secrets;print(secrets.token_hex(32))")
DATABASE_URL=postgresql+psycopg2://iam_user:CHANGE_ME_STRONG@localhost:5432/iam

In production the app refuses to boot unless SECRET_KEY, JWT_SECRET_KEY and DATABASE_URL are all set, and the two keys differ.

4. Migrate & seed

cd /opt/sims/app
/opt/sims/venv/bin/python -m alembic upgrade head
/opt/sims/venv/bin/flask --app src.app seed   # standard roles + permissions

Create the first super-admin via flask shell (assign the super_admin role in an org).

5. Gunicorn systemd unit — /etc/systemd/system/sims.service

[Unit]
Description=SIMS IAM API
After=network.target postgresql.service

[Service]
User=www-data
WorkingDirectory=/opt/sims/app
EnvironmentFile=/opt/sims/app/.env
ExecStart=/opt/sims/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 "src.app:create_app()"
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now sims          # start
sudo systemctl restart sims               # restart after deploy
sudo systemctl stop sims                  # stop

Multi-worker security state (SIMS-189). The auth rate limiter (src/middlewares/security.py) and the pending-token JTI replay guard (src/utils/jwt_utils.py) are backed by the database — the rate_limit_buckets and used_pending_jtis tables — whenever the app runs production-like (DEBUG and TESTING both off; see src.utils.shared_state.use_shared_store), which is always true for this systemd unit. That makes both correct across the 3 Gunicorn workers above: each worker used to keep its own in-memory copy, so a worker that hadn't seen a given IP or a given pending token yet would silently let a rate-limited request or a replayed token through. Local dev/test keep the original in-process dict/deque instead (no extra setup needed to run the test suite). No separate service (e.g. Redis) is required — this reuses DATABASE_URL, already configured above. Verify locally with python3 scripts/repro_multiworker_state.py, which boots the app under 2 real Gunicorn workers and confirms both the rate limit and the replay guard are enforced across them. Expired rows in both tables are purged by the existing flask --app src.app cleanup-tokens cron job (§ install output, "Optional — token cleanup cron").

6. Nginx — /etc/nginx/sites-available/sims

server {
    listen 80;
    server_name iam.example.com;
    return 301 https://$host$request_uri;   # HTTPS only
}

server {
    listen 443 ssl;
    server_name iam.example.com;

    ssl_certificate     /etc/letsencrypt/live/iam.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/iam.example.com/privkey.pem;

    location /static/ {
        alias /opt/sims/app/src/static/;
    }
    location / {
        proxy_pass http://127.0.0.1:8000;
        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;
    }
}
sudo ln -s /etc/nginx/sites-available/sims /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Use Certbot (sudo certbot --nginx) for the TLS certificate.

7. Verifying the deployment

After the service and proxy are up, the site serves three public entry points:

URL Purpose
/ Public landing page (no auth)
/docs Rendered documentation site (API ref, deploy guide, ER diagram, dev guide, spec)
/admin/login Admin portal login
/api/health Liveness + database connectivity check
curl -sI http://127.0.0.1:8000/        | head -1   # expect 200
curl -s  http://127.0.0.1:8000/api/health         # expect success envelope

8. Deploy updates

cd /opt/sims/app && git pull
/opt/sims/venv/bin/pip install -r requirements.txt
/opt/sims/venv/bin/python -m alembic upgrade head
sudo systemctl restart sims

9. Secret-key rotation (no downtime)

JWT_SECRET_KEY rotation invalidates outstanding access tokens (max 15 min lifetime), so refresh tokens let clients recover automatically. To rotate gracefully: deploy the new key, then over the next 15 minutes clients refresh and obtain tokens signed with the new key. For zero failed requests, support a short overlap by validating against both old and new keys during the window (extend decode_access_token to try a list of keys).

10. Production security checklist

  • [ ] APP_ENV=production, Flask debug disabled
  • [ ] HTTPS enforced (HTTP → 301); HSTS header emitted by the app
  • [ ] SECRET_KEYJWT_SECRET_KEY, both ≥32 bytes
  • [ ] PostgreSQL not exposed publicly; strong DB password
  • [ ] Regular alembic upgrade head on deploy
  • [ ] Audit logs retained and backed up
  • [ ] Rate limiter / JTI replay guard verified across workers (SIMS-189): python3 scripts/repro_multiworker_state.py

Back to documentation index