# PassKeeper — Deployment Guide Complete step-by-step instructions for deploying PassKeeper on a fresh Ubuntu server from zero to a fully running production environment. **Stack:** Ubuntu 22.04 LTS · Python 3.12 · MySQL 8 · Redis · Gunicorn · Nginx · Certbot (Let's Encrypt) **Deployment paths used throughout this guide:** - App directory: `/home/spuser/PassKeeper` - Virtual env: `/home/spuser/.venv` - Logs: `/home/spuser/logs/` - Backups: `/home/spuser/backups/passkeeper/` - System user: `spuser` (runs the app process) - Domain: `pwkeeper.ngodanguyen.tech` > Replace all occurrences of `pwkeeper.ngodanguyen.tech`, `spuser`, and passwords with > your own values before running any command. --- ## Prerequisites - A VPS or dedicated server running **Ubuntu 22.04 LTS** - A registered domain name with an **A record** pointing to your server's public IP - SSH access as a user with `sudo` privileges - The PassKeeper source code (this repository) available on the server --- ## Step 1 — System packages ```bash sudo apt update && sudo apt upgrade -y sudo apt install -y \ python3.12 \ python3.12-venv \ python3-pip \ mysql-server \ redis-server \ nginx \ certbot \ python3-certbot-nginx \ git \ curl \ ufw ``` Verify Python version: ```bash python3.12 --version # must be 3.12.x ``` --- ## Step 2 — Firewall Allow only SSH, HTTP, and HTTPS. Everything else is dropped. ```bash sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable sudo ufw status ``` --- ## Step 3 — MySQL setup ### 3a. Secure the MySQL installation ```bash sudo mysql_secure_installation ``` Follow the prompts: set a root password, remove anonymous users, disallow remote root login, remove the test database. ### 3b. Create the database and application user ```bash sudo mysql -u root -p ``` Inside the MySQL shell: ```sql CREATE DATABASE passkeeper CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE USER 'spuser'@'127.0.0.1' IDENTIFIED BY 'your-strong-db-password-here'; GRANT ALL PRIVILEGES ON passkeeper.* TO 'spuser'@'127.0.0.1'; FLUSH PRIVILEGES; EXIT; ``` > Use a strong, unique password. Update `MYSQL_PASSWORD` in `.env` (Step 6) to match. Verify the connection: ```bash mysql -u spuser -p -h 127.0.0.1 passkeeper -e "SELECT 1;" ``` --- ## Step 4 — Redis setup Redis is required for shared rate limiting across all Gunicorn workers. ```bash sudo systemctl enable redis-server sudo systemctl start redis-server redis-cli ping # should return PONG ``` Redis listens on `127.0.0.1:6379` by default. No password is required for local-only use. If you want a Redis password, set it in `/etc/redis/redis.conf` and update `RATELIMIT_STORAGE_URI` in `.env` accordingly. --- ## Step 5 — Deploy the application code ### 5a. Clone or upload the repository ```bash cd /home/spuser git clone https://github.com/your-org/passkeeper.git PassKeeper # — or — upload via scp/rsync: # scp -r ./PassKeeper spuser@your-server:/home/spuser/PassKeeper ``` ### 5b. Create directory structure ```bash mkdir -p /home/spuser/logs mkdir -p /home/spuser/backups/passkeeper chmod 700 /home/spuser/backups/passkeeper ``` ### 5c. Create the Python virtual environment ```bash cd /home/spuser python3.12 -m venv .venv source .venv/bin/activate pip install --upgrade pip pip install -r PassKeeper/requirements.txt ``` Verify key packages installed: ```bash pip show flask gunicorn cryptography redis pyotp ``` --- ## Step 6 — Configure environment variables ```bash cd /home/spuser/PassKeeper cp .env.example .env # if an example file exists # — or — create from scratch: nano .env ``` Paste the following, replacing every placeholder value: ```ini FLASK_ENV=production SECRET_KEY= JWT_SECRET_KEY= MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=spuser MYSQL_PASSWORD=your-strong-db-password-here MYSQL_DB=passkeeper ARGON2_TIME_COST=3 ARGON2_MEMORY_COST=65536 ARGON2_PARALLELISM=4 # AES-256-GCM key for encrypting TOTP secrets at rest (REQUIRED) # Generate: python3 -c "import secrets; print(secrets.token_hex(32))" TOTP_ENCRYPTION_KEY= # Redis for shared rate limiting across Gunicorn workers RATELIMIT_STORAGE_URI=redis://127.0.0.1:6379/0 # Lock CORS to your production domain CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech # Backup settings BACKUP_DIR=/home/spuser/backups/passkeeper BACKUP_RETENTION_DAYS=30 ``` Secure the file — it contains secrets: ```bash chmod 600 /home/spuser/PassKeeper/.env ``` Generate all three keys in one go: ```bash python3 -c "import secrets; [print(secrets.token_hex(32)) for _ in range(3)]" ``` Use the three output lines for `SECRET_KEY`, `JWT_SECRET_KEY`, and `TOTP_ENCRYPTION_KEY` respectively. --- ## Step 7 — Initialise the database schema Activate the virtual environment if not already active: ```bash source /home/spuser/.venv/bin/activate cd /home/spuser/PassKeeper ``` ### Option A — Fresh install (no existing data) ```bash python reset_db.py ``` This drops and recreates all tables using `db.create_all()`. Safe only on a fresh database with no data you care about. ### Option B — Existing database (run all migrations in order) ```bash export FLASK_APP=wsgi.py export FLASK_ENV=production flask db upgrade ``` This runs the three Alembic migrations in sequence: 1. `71d7158dd3b9` — creates `audit_logs`, fixes `sharing_public_key` type 2. `a1b2c3d4e5f6` — widens `totp_secret` to `VARCHAR(255)`, adds `totp_iv` 3. `b2c3d4e5f6a7` — adds `recovery_enc_salt` and `recovery_iv` ### Re-encrypt existing TOTP secrets (only if migrating from an older version) If you had users with plaintext TOTP secrets before Phase 5, run this one-time script: ```bash python scripts/reencrypt_totp_secrets.py ``` This is idempotent — it skips users who already have `totp_iv` set and rolls back atomically on any error. --- ## Step 8 — Test Gunicorn manually Before installing the systemd service, verify Gunicorn can start the app: ```bash source /home/spuser/.venv/bin/activate cd /home/spuser/PassKeeper gunicorn --workers 4 --bind 127.0.0.1:5000 --preload wsgi:app ``` You should see lines like: ``` [INFO] Listening at: http://127.0.0.1:5000 [INFO] Booting worker with pid: ... ``` Press `Ctrl+C` to stop. If you see errors, check `.env` values and DB connectivity. --- ## Step 9 — systemd service ### 9a. Install the service unit ```bash sudo cp /home/spuser/PassKeeper/scripts/passkeeper.service \ /etc/systemd/system/passkeeper.service ``` ### 9b. Verify the service file paths match your deployment ```bash sudo nano /etc/systemd/system/passkeeper.service ``` Confirm these lines are correct for your setup: ```ini User=spuser WorkingDirectory=/home/spuser/PassKeeper EnvironmentFile=/home/spuser/PassKeeper/.env ExecStart=/home/spuser/.venv/bin/gunicorn ... --access-logfile /home/spuser/logs/access.log --error-logfile /home/spuser/logs/error.log ``` ### 9c. Enable and start ```bash sudo systemctl daemon-reload sudo systemctl enable passkeeper sudo systemctl start passkeeper ``` ### 9d. Verify it is running ```bash sudo systemctl status passkeeper ``` You should see `Active: active (running)`. If not: ```bash journalctl -xeu passkeeper.service --no-pager | tail -50 ``` Test that Gunicorn is listening: ```bash curl -s http://127.0.0.1:5000/ | head -20 ``` --- ## Step 10 — Nginx configuration ### 10a. Install the site config ```bash sudo cp /home/spuser/PassKeeper/scripts/passkeeper-nginx.conf \ /etc/nginx/sites-available/passkeeper ``` ### 10b. Update the domain name ```bash sudo nano /etc/nginx/sites-available/passkeeper ``` Replace `pwkeeper.ngodanguyen.tech` and `passkeeper.ngodanguyen.tech` with your actual domain(s) in the `server_name` directives. Also update the static files alias if your deployment path differs: ```nginx location /static/ { alias /home/spuser/PassKeeper/app/static/; ... } ``` ### 10c. Enable the site ```bash sudo ln -s /etc/nginx/sites-available/passkeeper /etc/nginx/sites-enabled/ sudo nginx -t # must output: configuration file ... syntax is ok sudo systemctl reload nginx ``` --- ## Step 11 — TLS certificate (Let's Encrypt) Your DNS A record must already be pointing to this server before running Certbot. ```bash sudo certbot --nginx -d pwkeeper.ngodanguyen.tech ``` Follow the prompts. When Certbot asks about redirects, choose option **2** (redirect HTTP to HTTPS). Certbot will automatically modify the Nginx config to add the `ssl_certificate` paths and HTTP → HTTPS redirect block. Verify auto-renewal works: ```bash sudo certbot renew --dry-run ``` Confirm the final Nginx config is valid and reload: ```bash sudo nginx -t && sudo systemctl reload nginx ``` ### Test from a browser Open `https://pwkeeper.ngodanguyen.tech` — you should see the PassKeeper login page served over HTTPS with a valid certificate. --- ## Step 12 — Log rotation ```bash sudo cp /home/spuser/PassKeeper/scripts/passkeeper-logrotate \ /etc/logrotate.d/passkeeper ``` Verify the config is valid: ```bash sudo logrotate -d /etc/logrotate.d/passkeeper ``` Force an immediate test rotation (creates `.1.gz` files): ```bash sudo logrotate -f /etc/logrotate.d/passkeeper ls -lh /home/spuser/logs/ ``` --- ## Step 13 — Automated database backups ### 13a. Make the backup script executable ```bash chmod +x /home/spuser/PassKeeper/scripts/backup_db.sh ``` ### 13b. Test the backup script manually ```bash bash /home/spuser/PassKeeper/scripts/backup_db.sh ls -lh /home/spuser/backups/passkeeper/ ``` You should see a `.sql.gz` file. Check the log: ```bash cat /home/spuser/backups/passkeeper/backup.log ``` ### 13c. Schedule via cron ```bash crontab -e ``` Add the following line (runs daily at 2:00 AM): ``` 0 2 * * * /bin/bash /home/spuser/PassKeeper/scripts/backup_db.sh >> /home/spuser/backups/passkeeper/backup.log 2>&1 ``` Save and exit. Verify the cron entry was saved: ```bash crontab -l ``` --- ## Step 14 — Final verification checklist Run through each item to confirm the deployment is healthy: ```bash # 1. MySQL is running and the DB is accessible mysql -u spuser -p -h 127.0.0.1 passkeeper -e "SHOW TABLES;" # 2. Redis is running redis-cli ping # 3. Gunicorn service is active sudo systemctl status passkeeper # 4. Nginx is active and config is valid sudo systemctl status nginx sudo nginx -t # 5. App responds on localhost curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5000/ # 6. App responds on HTTPS (replace with your domain) curl -s -o /dev/null -w "%{http_code}" https://pwkeeper.ngodanguyen.tech/ # 7. Security headers are present curl -sI https://pwkeeper.ngodanguyen.tech/ | grep -E "Strict-Transport|X-Frame|X-Content|Content-Security" # 8. Gunicorn logs are being written tail -20 /home/spuser/logs/access.log tail -20 /home/spuser/logs/error.log # 9. No errors in the systemd journal journalctl -u passkeeper --since "5 minutes ago" --no-pager ``` Expected HTTP status codes: `200` from both `127.0.0.1:5000` and `https://yourdomain`. --- ## Routine Operations ### Deploying a code update ```bash cd /home/spuser/PassKeeper # Pull new code git pull origin main # Install any new dependencies source /home/spuser/.venv/bin/activate pip install -r requirements.txt # Run any new database migrations export FLASK_APP=wsgi.py FLASK_ENV=production flask db upgrade # Reload Gunicorn zero-downtime (sends USR2 to master) sudo systemctl reload passkeeper # Verify sudo systemctl status passkeeper ``` ### Restarting the app (full restart, brief downtime) ```bash sudo systemctl restart passkeeper ``` ### Viewing live logs ```bash # Gunicorn access log (all HTTP requests) tail -f /home/spuser/logs/access.log # Gunicorn error log (Python exceptions, startup errors) tail -f /home/spuser/logs/error.log # systemd journal (service lifecycle events) journalctl -fu passkeeper ``` ### Checking rate limit behaviour (Redis) ```bash redis-cli > KEYS * > DBSIZE ``` ### Manual database backup ```bash bash /home/spuser/PassKeeper/scripts/backup_db.sh ``` ### Restoring a database backup ```bash # List available backups ls -lh /home/spuser/backups/passkeeper/*.sql.gz # Restore (replace filename with the one you want) gunzip -c /home/spuser/backups/passkeeper/passkeeper_20260418_020001.sql.gz \ | mysql -u spuser -p -h 127.0.0.1 passkeeper ``` ### Renewing the TLS certificate manually ```bash sudo certbot renew sudo systemctl reload nginx ``` Certbot auto-renewal runs twice daily via a systemd timer — manual renewal is usually only needed for testing. --- ## Troubleshooting ### `502 Bad Gateway` from Nginx Gunicorn is down or not listening. Check: ```bash sudo systemctl status passkeeper journalctl -xeu passkeeper.service --no-pager | tail -30 curl http://127.0.0.1:5000/ ``` Common causes: wrong path in `EnvironmentFile`, missing `.env` variable, MySQL not reachable at startup. Fix the root cause, then: ```bash sudo systemctl restart passkeeper ``` ### `Connection refused` on port 5000 Gunicorn crashed. Check the error log: ```bash tail -50 /home/spuser/logs/error.log ``` If it's a startup error (e.g. bad `TOTP_ENCRYPTION_KEY` format), fix `.env` and restart. ### `500 Internal Server Error` Application exception. Check: ```bash tail -50 /home/spuser/logs/error.log journalctl -u passkeeper --since "1 minute ago" --no-pager ``` ### Database connection errors ```bash # Test connectivity directly mysql -u spuser -p -h 127.0.0.1 passkeeper -e "SELECT 1;" # Check MySQL is running sudo systemctl status mysql # Check .env has correct credentials grep MYSQL /home/spuser/PassKeeper/.env ``` ### Certbot / TLS renewal fails ```bash # Check DNS is resolving to this server dig +short pwkeeper.ngodanguyen.tech # Check Nginx serves port 80 (needed for ACME challenge) sudo nginx -t sudo systemctl status nginx ``` ### Rate limiting too aggressive (429 errors) Check Redis is running (if Redis is down, Flask-Limiter falls back to in-memory counters that reset on every request, which may cause unexpected behaviour): ```bash redis-cli ping sudo systemctl status redis-server ``` --- ## Environment Variable Reference | Variable | Required | Description | | ----------------------- | -------- | ---------------------------------------------------------------- | | `FLASK_ENV` | Yes | `production` or `development` | | `SECRET_KEY` | Yes | Flask session signing key — 32-byte hex | | `JWT_SECRET_KEY` | Yes | JWT signing key — 32-byte hex | | `MYSQL_HOST` | Yes | MySQL host (usually `127.0.0.1`) | | `MYSQL_PORT` | Yes | MySQL port (usually `3306`) | | `MYSQL_USER` | Yes | MySQL username | | `MYSQL_PASSWORD` | Yes | MySQL password | | `MYSQL_DB` | Yes | Database name (`passkeeper`) | | `TOTP_ENCRYPTION_KEY` | Yes | 64-char hex — AES-256-GCM key for TOTP secrets | | `RATELIMIT_STORAGE_URI` | Yes | `redis://127.0.0.1:6379/0` in production | | `CORS_ORIGINS` | Yes | Your production domain, e.g. `https://pwkeeper.ngodanguyen.tech` | | `ARGON2_TIME_COST` | No | Default `3` | | `ARGON2_MEMORY_COST` | No | Default `65536` (64 MB) | | `ARGON2_PARALLELISM` | No | Default `4` | | `BACKUP_DIR` | No | Default `/home/spuser/backups/passkeeper` | | `BACKUP_RETENTION_DAYS` | No | Default `30` |