A self-contained productivity tool for Red Hat Solutions Architects. Connects to Gmail, Google Drive, and Slack to surface follow-ups, contacts, accounts, and an AI-generated one-pager — all refreshed hourly.
| Page | Description |
|---|---|
| One-Pager | AI-drafted status doc (priorities, accounts, ideas, wins) — refreshed hourly, pin sections to preserve your edits |
| Follow-Ups | Auto-detects email threads you sent with no reply in 7+ days |
| Contacts | All external contacts extracted from your Gmail history, searchable |
| Accounts | Contacts grouped by company, expandable rows |
- Backend: Python / FastAPI + SQLite + APScheduler
- Frontend: React + Vite served by Nginx
- AI: Ollama with Qwen2.5 7B (contact enrichment + one-pager generation, self-hosted)
- Runtime: Docker Compose
- Go to console.cloud.google.com
- Create a new project (e.g.
sa-hub) - Enable these APIs:
- Gmail API
- Google Drive API (used to read Google Docs for the one-pager; separate Docs API is not required)
- Go to APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID
- Application type: Web application
- Authorized redirect URI:
http://localhost:3000/api/auth/google/callback(or your HTTPS domain — see below)
- Copy the Client ID and Client Secret
cp .env.example .envEdit .env:
GOOGLE_CLIENT_ID=your_client_id_here
GOOGLE_CLIENT_SECRET=your_client_secret_here
SECRET_KEY=any_random_32_char_stringOptional LLM settings (defaults work with Docker Compose):
LLM_BASE_URL=http://ollama:11434/v1
LLM_MODEL=qwen2.5:7b-instructdocker compose up --buildPodman / Docker build fails with ERR_MODULE_NOT_FOUND and node_modules/dist/node/cli.js: the image copied your host node_modules over npm ci. Ensure frontend/.dockerignore exists (it excludes node_modules), then rebuild:
docker compose build --no-cache frontendPull the model into Ollama (first time only, ~4 GB download):
docker exec sa-hub-ollama ollama pull qwen2.5:7b-instruct- Frontend: http://localhost:3000
- Backend API: http://localhost:8000
- Ollama API: http://localhost:11434
Go to Connections in the sidebar → click Connect Google → complete OAuth flow.
Then click Sync Now to run the first sync (Gmail all-time history may take a few minutes).
Corporate Slack requires admin approval for custom app installs.
To request:
- Go to https://api.slack.com/apps → Create New App
- Request these Bot Token Scopes from IT:
channels:history,channels:readgroups:history,im:history,mpim:historyusers:read
- Once approved and installed, copy the Bot Token to
.env:SLACK_BOT_TOKEN=xoxb-... - Restart:
docker compose restart backend
A domain you own with HTTPS fixes the Google redirect issue. Google accepts https://sa-hub.yourdomain.com/...; it does not accept raw LAN IPs like 192.168.x.x.
Typical layout (Caddy and Docker on the same RHEL host):
Internet → UDM :443/:80 → RHEL host (Caddy :443)
└→ http://127.0.0.1:3000 (SA Hub frontend container)
Do not forward WAN ports 3000, 8000, or 11434 — only 443 (and 80 for Let's Encrypt).
At your DNS provider, add an A record:
| Type | Name | Value |
|---|---|---|
| A | sa-hub |
Your home public IPv4 |
Result: sa-hub.yourdomain.com → your WAN IP. Verify with dig +short sa-hub.yourdomain.com.
On your UniFi Dream Machine, forward 443 and 80 to this RHEL box's LAN IP.
cd /path/to/sa-hub
mkdir -p data
cp .env.example .env
# edit .env (see below)
docker compose up -d --build
docker exec sa-hub-ollama ollama pull qwen2.5:7b-instructRHEL / SELinux: Compose mounts ./data with the :z flag so the container can write SQLite. Do not bind-mount ./backend over /app in production. Do not use docker compose with --reload on RHEL (see troubleshooting §7).
Confirm the UI on the host:
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000 # expect 200PUBLIC_URL=https://sa-hub.yourdomain.com
FRONTEND_URL=https://sa-hub.yourdomain.com
GOOGLE_REDIRECT_URI=https://sa-hub.yourdomain.com/api/auth/google/callback
SECRET_KEY=generate_a_long_random_string
AUTH_USERNAME=your_username
AUTH_PASSWORD_HASH=<bcrypt hash — see below>Generate the password hash (never put a plain password in .env):
docker compose up -d --build backend
docker compose exec backend python scripts/hash_password.py 'your long random password'Copy the output into .env as AUTH_PASSWORD_HASH=..., remove any AUTH_PASSWORD= line, then:
docker compose up -d backendOptional — Docker secret file (hash not in .env):
docker compose exec backend python scripts/hash_password.py 'your password' > secrets/auth_password_hash
chmod 600 secrets/auth_password_hash
docker compose -f docker-compose.yml -f docker-compose.secrets.yml.example up -d- Set the same redirect URI in Google Cloud Console → OAuth client → Authorized redirect URIs (no trailing slash).
- Restart:
docker compose up -d
When AUTH_USERNAME and AUTH_PASSWORD_HASH (or legacy AUTH_PASSWORD) are set, all API routes and Google connect require signing in first. Sessions last 30 days by default (AUTH_SESSION_DAYS).
Passwords are verified with bcrypt; only the hash is stored. Plain AUTH_PASSWORD still works but logs a deprecation warning.
If auth variables are unset, the app stays open (fine for localhost-only).
Caddy runs on the host (not in docker-compose). It terminates TLS and proxies to the frontend container on port 3000.
RHEL 9 / Rocky 9 / Alma 9:
sudo dnf install -y dnf-plugins-core
sudo dnf copr enable -y @caddy/caddy
sudo dnf install -y caddy
sudo systemctl enable --now caddyRHEL 8: If Copr is unavailable, install the binary from caddyserver.com/docs/install and add a systemd unit.
Edit /etc/caddy/Caddyfile (or merge with existing sites). Example in repo: deploy/Caddyfile.example.
sa-hub.yourdomain.com {
reverse_proxy localhost:3000 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Real-IP {remote_host}
}
}Replace sa-hub.yourdomain.com with your hostname.
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -f # logs if something failsCaddy obtains a Let's Encrypt certificate automatically when DNS and ports 80/443 reach this host.
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reloadIf HTTPS loads but Caddy returns 502 or logs show connection errors to the backend:
sudo setsebool -P httpd_can_network_connect 1
sudo systemctl reload caddyCheck denials: sudo ausearch -m avc -ts recent
Only one service can bind 443:
sudo ss -tlnp | grep ':443'Caddy should own 443. If another service uses it, add the sa-hub.yourdomain.com block to that proxy instead of running two listeners.
curl -I https://sa-hub.yourdomain.com
# API health through the same path as the UI (via Caddy → frontend nginx → backend)
curl -s https://sa-hub.yourdomain.com/api/healthFrom off your home network:
- Open
https://sa-hub.yourdomain.com— valid padlock, login page - Sign in
- Connections → Connect Google — completes without redirect errors
1. Read logs (crash vs slow start):
docker compose logs backend --tail=80| Log message | Fix |
|---|---|
ModuleNotFoundError: No module named 'bcrypt' |
docker compose build --no-cache backend && docker compose up -d |
Permission denied on /app or Will watch for changes |
See §8 restart loop (remove --reload / backend bind mount) |
unable to open database file |
mkdir -p data && chmod 755 data and SELinux :z on volume |
Uvicorn running on http://0.0.0.0:8000 |
App is up; healthcheck may need more time — pull latest compose (start_period: 60s) |
2. Test health inside the container:
docker compose exec backend python scripts/healthcheck.py
echo $?Exit code 0 = healthy. 1 = app not listening yet or crashed.
3. Test from the host:
curl -s http://localhost:8000/health4. Bcrypt hash in .env — escape $
Docker Compose treats $ as variable substitution. A line like AUTH_PASSWORD_HASH=$2b$12$... corrupts the hash and can break login. Either:
- Double every
$:AUTH_PASSWORD_HASH=$$2b$$12$$..., or - Use
secrets/auth_password_hash+docker-compose.secrets.yml.example(no$in.env)
5. Auth env must be consistent
docker compose exec backend printenv AUTH_USERNAME AUTH_PASSWORD_HASH SECRET_KEYIf AUTH_USERNAME is set, you need AUTH_PASSWORD_HASH (or legacy AUTH_PASSWORD) and SECRET_KEY.
6. Frontend stuck waiting
The frontend waits for a healthy backend. Fix backend first, then docker compose up -d.
If logs show both of these, uvicorn is running with --reload (file watcher), which fails on RHEL/SELinux:
INFO: Will watch for changes in these directories: ['/app']
PermissionError: Permission denied (os error 13) about ["/app"]
Fix:
- Remove auto-override — Docker Compose silently merges
docker-compose.override.ymlif it exists:
ls -la docker-compose.override.yml
# If present, delete or rename it:
rm docker-compose.override.yml- Confirm the running command (must NOT contain
--reload):
docker inspect sa-hub-backend --format '{{json .Config.Cmd}}'- Rebuild the backend image from scratch and recreate:
docker compose down
docker compose build --no-cache backend
docker compose up -d
docker compose logs backend --tail=15You should see Uvicorn running on http://0.0.0.0:8000 without “Will watch for changes”.
-
Use current
docker-compose.ymlonly (./data:/app/data:z, explicitcommandwithout--reload). -
Ensure data is writable:
mkdir -p data && chmod 755 data -
If SQLite still fails on
data/:
sudo chcon -Rt svirt_sandbox_file_t dataDev reload (not for RHEL production): use docker compose -f docker-compose.yml -f docker-compose.dev.yml.example only on a dev machine — never copy that file to docker-compose.override.yml on the server.
Google requires the redirect URI in the OAuth request to exactly match one entry in Cloud Console (scheme, host, port, path — no trailing slash).
1. See what SA Hub is sending (after signing in, open Connections, or run):
docker compose exec backend python -c "
import os
from services.google_auth import get_redirect_uri
print(get_redirect_uri())
"2. Add that exact URI in Google Cloud Console → APIs & Services → Credentials → your OAuth client → Authorized redirect URIs.
3. Typical mismatches
| In Console (wrong) | SA Hub expects (HTTPS production) |
|---|---|
http://localhost:8000/auth/google/callback |
https://sa-hub.yourdomain.com/api/auth/google/callback |
http://localhost:3000/api/... |
https://sa-hub.yourdomain.com/api/... |
https://sa-hub.yourdomain.com/api/.../ (trailing /) |
same URI without trailing slash |
4. Align .env on the server (then docker compose up -d backend):
PUBLIC_URL=https://sa-hub.yourdomain.com
FRONTEND_URL=https://sa-hub.yourdomain.com
GOOGLE_REDIRECT_URI=https://sa-hub.yourdomain.com/api/auth/google/callbackGOOGLE_REDIRECT_URI can be omitted if it is PUBLIC_URL + /api/auth/google/callback (do not copy { } braces from the Caddyfile into .env).
5. WiFiMan / SSH tunnel only: use http://localhost:3000/api/auth/google/callback in both Console and .env (or unset PUBLIC_URL), and open the app at http://localhost:3000 via SSH forward — not the LAN IP.
Gmail and Drive share one Google login, but Drive is a separate API in Cloud Console.
Common causes:
| Symptom | Fix |
|---|---|
accessNotConfigured / API not enabled |
Enable Google Drive API in API Library |
insufficient permissions |
Re-connect Google (Connections → disconnect by clearing token or re-run OAuth with prompt=consent) |
| Docs on Shared drives | Fixed in current code (includeItemsFromAllDrives); rebuild backend |
| Empty docs list | Normal if you have no Google Docs; sync still succeeds |
Check the error:
docker compose logs backend --tail=50 | grep -i driveOr open Connections in the UI — Drive sync shows the last status message.
After updating the backend:
docker compose up -d --build backendClick Sync Now, then refresh Connections.
A 502 Bad Gateway when clicking Connect almost always means the reverse proxy cannot reach the backend container (not a Google error).
1. Test each hop on the RHEL host
# Backend directly
curl -s http://localhost:8000/health
# Through frontend nginx (same path as the browser)
curl -s http://localhost:3000/api/health
# Through Caddy (if configured)
curl -s https://sa-hub.yourdomain.com/api/healthAll should return {"status":"ok"}. If step 1 fails, check backend logs:
docker compose logs backend --tail=50If step 1 works but step 2 returns 502, rebuild the frontend (nginx proxies /api/ to backend:8000):
docker compose up -d --build frontend2. Confirm Google env vars are loaded
docker compose exec backend printenv GOOGLE_CLIENT_IDEmpty means .env is missing values or compose was not restarted after editing .env:
docker compose up -d3. Sign in before Connect
If AUTH_USERNAME / AUTH_PASSWORD are set, you must sign in first. The Connect link is a full-page navigation; your session cookie must be present. You should not see a login page after clicking Connect.
4. SELinux (RHEL)
If the UI loads but /api/* returns 502:
sudo setsebool -P httpd_can_network_connect 1
sudo systemctl reload caddy5. Caddy must forward HTTPS headers
Use the header_up block in deploy/Caddyfile.example, then sudo systemctl reload caddy.
6. Google redirect URI
A redirect mismatch usually shows a Google error page, not 502. Ensure Console and .env match exactly:
https://sa-hub.yourdomain.com/api/auth/google/callback
Confirm you are on the current build — backend logs should say Starting sync... (not Starting hourly sync...) after Sync Now. Rebuild both images:
podman-compose build --no-cache backend frontend
podman-compose up -dTrailing } in .env — if logs show Stripped trailing '}' from URL env value, fix lines like PUBLIC_URL=https://sa-hub.digitalgiants.net} (remove the }).
Gmail works but enrichment fails — sync continues past Gmail; the next step calls Ollama. Pull the model once:
podman exec -it sa-hub-ollama ollama pull qwen2.5:7b-instructOn startup, the backend logs a warning if the model is missing. After a failed sync, check the full traceback:
podman-compose logs backend --tail=80 | grep -A5 "Contact enrichment"To skip AI steps until Ollama is ready, add to .env:
SYNC_AI_ENABLED=falseDo not click Sync Now twice — wait for the spinner to finish (several minutes on first Gmail sync). Overlapping syncs slow the server.
database is locked during sync — rebuild the backend (enables SQLite WAL + per-step transactions). If it persists, ensure only one sync runs and ./data is on local disk, not NFS.
Kicked to sign-in while sync runs — rebuild the frontend so /auth/session failures from timeouts do not redirect to login (only HTTP 401 should). Backend /auth/session returning 200 in logs means your session is still valid.
You can still use WiFiMan and only use the site over VPN, or combine VPN + app login for defense in depth.
Use WiFiMan Desktop on your laptop to join your home LAN over the VPN built into your UniFi Dream Machine. You do not need to expose SA Hub to the public internet.
Google does not allow redirect URIs like http://192.168.x.x/.... You will see errors such as “must end with a public top-level domain” or “valid top private domain”. Allowed options for a personal setup:
| Redirect URI | Works? |
|---|---|
http://localhost:3000/api/auth/google/callback |
Yes |
http://127.0.0.1:3000/api/auth/google/callback |
Yes |
http://192.168.1.50:3000/... |
No |
https://yourdomain.com/... |
Yes (real domain + HTTPS) |
Recommended with WiFiMan: keep OAuth on localhost and use an SSH port forward from your laptop to the SA Hub host (below). Leave PUBLIC_URL / GOOGLE_REDIRECT_URI unset in .env (defaults are fine).
In Google Cloud Console → OAuth client → Authorized redirect URIs, use only:
http://localhost:3000/api/auth/google/callback
Remove any 192.168.x.x entries you added.
- Open UniFi Network → your UDM → Settings → Teleport (or VPN).
- Enable it and link your Ubiquiti account for WiFiMan.
- Give the SA Hub machine a fixed LAN IP (e.g.
192.168.1.50).
- Connect WiFiMan Desktop to your site.
- Forward ports over SSH (replace user/IP):
ssh -N -L 3000:127.0.0.1:3000 youruser@192.168.1.50- Open http://localhost:3000 in the browser (not the
192.168.x.xURL).
Google sign-in and the rest of the app use the same origin; OAuth callbacks hit localhost on your laptop, which SSH forwards to the server.
For a public HTTPS domain on RHEL, see Production: your domain + HTTPS + login above.
- Do not forward ports 3000, 8000, or 11434 on the WAN firewall.
- Without
AUTH_*set, VPN/LAN users could use SA Hub by IP; set app login for production or use SSH + localhost only.
- All data is stored locally in
./data/sa_hub.db(SQLite) - Nothing is sent anywhere except to Google APIs (your own account); AI runs locally via Ollama
- Gmail access is read-only; the app never sends emails
Backend (requires Ollama running locally on port 11434):
cd backend
pip install -r requirements.txt
cp ../.env.example ../.env # fill in values
# For local dev, point at host Ollama:
export LLM_BASE_URL=http://localhost:11434/v1
uvicorn main:app --reload --port 8000Frontend:
cd frontend
npm install
npm run dev # runs on :5173, proxies /api to :8000