Files
PassKeeper/Deloy.md
T
2026-04-18 21:00:19 -04:00

16 KiB

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

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:

python3.12 --version   # must be 3.12.x

Step 2 — Firewall

Allow only SSH, HTTP, and HTTPS. Everything else is dropped.

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

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

sudo mysql -u root -p

Inside the MySQL shell:

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:

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.

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

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

mkdir -p /home/spuser/logs
mkdir -p /home/spuser/backups/passkeeper
chmod 700 /home/spuser/backups/passkeeper

5c. Create the Python virtual environment

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:

pip show flask gunicorn cryptography redis pyotp

Step 6 — Configure environment variables

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:

FLASK_ENV=production
SECRET_KEY=<generate: python3 -c "import secrets; print(secrets.token_hex(32))">
JWT_SECRET_KEY=<generate: python3 -c "import secrets; print(secrets.token_hex(32))">

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=<generate as above — 64 hex characters>

# 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:

chmod 600 /home/spuser/PassKeeper/.env

Generate all three keys in one go:

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:

source /home/spuser/.venv/bin/activate
cd /home/spuser/PassKeeper

Option A — Fresh install (no existing data)

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)

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:

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:

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

sudo cp /home/spuser/PassKeeper/scripts/passkeeper.service \
        /etc/systemd/system/passkeeper.service

9b. Verify the service file paths match your deployment

sudo nano /etc/systemd/system/passkeeper.service

Confirm these lines are correct for your setup:

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

sudo systemctl daemon-reload
sudo systemctl enable passkeeper
sudo systemctl start passkeeper

9d. Verify it is running

sudo systemctl status passkeeper

You should see Active: active (running). If not:

journalctl -xeu passkeeper.service --no-pager | tail -50

Test that Gunicorn is listening:

curl -s http://127.0.0.1:5000/ | head -20

Step 10 — Nginx configuration

10a. Install the site config

sudo cp /home/spuser/PassKeeper/scripts/passkeeper-nginx.conf \
        /etc/nginx/sites-available/passkeeper

10b. Update the domain name

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:

location /static/ {
    alias /home/spuser/PassKeeper/app/static/;
    ...
}

10c. Enable the site

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.

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:

sudo certbot renew --dry-run

Confirm the final Nginx config is valid and reload:

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

sudo cp /home/spuser/PassKeeper/scripts/passkeeper-logrotate \
        /etc/logrotate.d/passkeeper

Verify the config is valid:

sudo logrotate -d /etc/logrotate.d/passkeeper

Force an immediate test rotation (creates .1.gz files):

sudo logrotate -f /etc/logrotate.d/passkeeper
ls -lh /home/spuser/logs/

Step 13 — Automated database backups

13a. Make the backup script executable

chmod +x /home/spuser/PassKeeper/scripts/backup_db.sh

13b. Test the backup script manually

bash /home/spuser/PassKeeper/scripts/backup_db.sh
ls -lh /home/spuser/backups/passkeeper/

You should see a .sql.gz file. Check the log:

cat /home/spuser/backups/passkeeper/backup.log

13c. Schedule via cron

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:

crontab -l

Step 14 — Final verification checklist

Run through each item to confirm the deployment is healthy:

# 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

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)

sudo systemctl restart passkeeper

Viewing live logs

# 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)

redis-cli
> KEYS *
> DBSIZE

Manual database backup

bash /home/spuser/PassKeeper/scripts/backup_db.sh

Restoring a database backup

# 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

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:

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:

sudo systemctl restart passkeeper

Connection refused on port 5000

Gunicorn crashed. Check the error log:

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:

tail -50 /home/spuser/logs/error.log
journalctl -u passkeeper --since "1 minute ago" --no-pager

Database connection errors

# 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

# 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):

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