17 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
sudoprivileges - 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_PASSWORDin.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:
71d7158dd3b9— createsaudit_logs, fixessharing_public_keytypea1b2c3d4e5f6— widenstotp_secrettoVARCHAR(255), addstotp_ivb2c3d4e5f6a7— addsrecovery_enc_saltandrecovery_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.
Do not add
--preload.create_app()starts an APScheduler thread for the hourly cleanup oftoken_blacklist/recovery_challenges/totp_used_codes/ expired shares. Threads do not survivefork(), so under--preloadthe scheduler would live only in the arbiter — which serves no requests — and the cleanup would silently never run.gunicorn.conf.pypinspreload_app = Falsefor this reason.
source /home/spuser/.venv/bin/activate
cd /home/spuser/PassKeeper
gunicorn -c gunicorn.conf.py wsgi:app
# Validate the config without starting the server:
gunicorn --check-config -c gunicorn.conf.py 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
# Restart Gunicorn to pick up the new code.
# NOT `reload` — ExecReload sends HUP, which re-reads gunicorn.conf.py and
# recycles workers but does NOT reload changed Python source. Using reload after
# a code deploy leaves the old code running and looks like the deploy silently
# did nothing.
sudo systemctl restart 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 |