Admin Guide
PowerMarketing runs as a single Docker container backed by a SQLite database. This guide covers first deployment, configuration, day-to-day operations, and troubleshooting.
Architecture
Internet
│
▼
Nginx (HTTPS + CORS)
├─► marketing.planningpowertools.com → Static HTML/CSS/JS (web host)
└─► marketing-api.planningpowertools.com → powermarketing-api:8070 (Docker)
| Component | Details |
|---|---|
| Container | powermarketing-api running FastAPI + APScheduler on port 8070 |
| Database | SQLite at /data/powermarketing.db on volume powermarketing-data |
| Frontend | Static files served from any web host (no Node.js required) |
First Deployment
Step 1 — Generate AUTH_SECRET
Generate a strong random secret:
openssl rand -hex 32
Copy the output. This is your AUTH_SECRET. Losing it invalidates all active sessions.
Step 2 — Write docker-compose.yml
services:
powermarketing-api:
image: powermarketing-api:latest
container_name: powermarketing-api
restart: always
ports:
- "8070:8070"
volumes:
- powermarketing-data:/data
environment:
- DATABASE_PATH=/data/powermarketing.db
- SCHEMA_PATH=/app/schema.sql
- SEED_DATA_PATH=/app/seed-data.sql
- CORS_ORIGINS=*
- AUTH_SECRET=<your-generated-secret>
- ACCESS_TOKEN_EXPIRE_MINUTES=480
- BOOTSTRAP_ADMIN_USERNAME=admin
- BOOTSTRAP_ADMIN_PASSWORD=<strong-password-no-dollar-signs>
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8070/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- pdp-network
volumes:
powermarketing-data:
name: powermarketing-data
networks:
pdp-network:
external: true
Warning about $ in passwords: In docker-compose YAML, $$ is escaped to $. If your bootstrap password contains $, use $$ to represent each $. Example: password Pass$word → write Pass$$word in the YAML. The simplest approach is to use a password without $ for bootstrap, then change it via the UI after first login.
Step 3 — Start the container
docker compose up -d
Step 4 — Verify health
curl https://marketing-api.planningpowertools.com/health
# Expected: {"status":"healthy","service":"PowerMarketing"}
Step 5 — First login
Navigate to https://marketing.planningpowertools.com/login.html. Sign in with:
- Username: value of
BOOTSTRAP_ADMIN_USERNAME(default:admin) - Password: value of
BOOTSTRAP_ADMIN_PASSWORD
Change your password immediately after first login via Settings → Users → Change My Password.
Environment Variables Reference
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_PATH | Yes | /data/powermarketing.db | SQLite file path |
SCHEMA_PATH | Yes | ./schema.sql | Schema file for DB init |
SEED_DATA_PATH | Yes | ./seed-data.sql | Seed data for DB init |
CORS_ORIGINS | Yes | * | Comma-separated allowed origins |
AUTH_SECRET | Yes | change-me-in-production | JWT signing secret — must be changed |
ACCESS_TOKEN_EXPIRE_MINUTES | No | 480 | Session duration in minutes |
BOOTSTRAP_ADMIN_USERNAME | No | admin | First admin username |
BOOTSTRAP_ADMIN_PASSWORD | No | (empty) | First admin password — required for bootstrap |
PLATFORM_CLAUDE_API_KEY | No | (empty) | Shared fallback Claude key — see Shared AI Allowance below |
PLATFORM_DEEPSEEK_API_KEY | No | (empty) | Shared fallback DeepSeek key |
PLATFORM_MOONSHOT_API_KEY | No | (empty) | Shared fallback Moonshot key |
Per-brand LLM, Resend, and Tavily API keys are stored in the database — not here. The PLATFORM_* keys above are the operator's own, used only as a fallback for brands that have supplied none.
Shared AI Allowance
Without a key of its own a brand cannot generate anything, which blocks a non-technical user on day one. Setting a PLATFORM_* key lets eligible brands generate on yours instead.
Resolution order for every LLM call:
- The brand's own key for that provider.
- The platform key — only if the brand's plan has
platform_llm_enabled. - Otherwise an actionable HTTP 400 pointing at Settings → API Keys.
Two guards keep the cost bounded:
- Opt-in. The keys default to empty. Until you set one, behaviour is unchanged.
- Metered. Every fallback call is counted in
platform_llm_usage(one row per brand per month). When a brand reaches its plan'splatform_llm_calls_per_month, generation stops with a 400 telling the user to add their own key. A brand's own key is never metered.
Usage is shown to the user in Settings → API Keys and on the Command Center setup checklist ("12 of 100 calls used this month"). Reading that status does not consume quota.
User Management
Bootstrap Admin
On first startup, if no users exist and BOOTSTRAP_ADMIN_PASSWORD is set, a superadmin user is created. This only runs once. Check the container log for: Bootstrap: created superadmin 'admin'.
Creating Additional Users
As superadmin, go to the Users page (/users.html) and click + Add User. Assign brands and a role per user.
Roles
| Role | Access |
|---|---|
superadmin | All brands, user management, system settings |
admin | Assigned brands — full settings including credentials |
operator | Assigned brands — content, decisions, calendar, leads |
viewer | Assigned brands — read-only |
Brand Setup
New brands need 4 things before dispatch works: brand voice, content pillars, an LLM API key, and a calendar entry. The fastest path:
- Create the brand in Settings
- Go to Settings → Brand Profile → use Quick-Start (AI fills everything from 3 inputs)
- Go to Settings → API Keys → add the LLM provider key
- Create a calendar entry for today
- Run dispatch — the readiness check will tell you if anything is still missing
Per-Brand API Keys
All API keys are stored in the database per-brand, not in environment variables. Configure in Settings → API Keys.
| Key | Used for |
|---|---|
| Claude API key | Anthropic/Claude content generation |
| DeepSeek API key | DeepSeek content generation |
| Moonshot API key | Moonshot/Kimi content generation |
| Resend API key | Email broadcasts |
| Resend webhook secret | Email delivery events |
| Tavily API key | Live trend search |
Scheduler
The in-process APScheduler runs the following recurring jobs:
| Job | Schedule |
|---|---|
| Per-brand content dispatch | Polling every 15 min; fires when the brand's effective dispatch time (fixed dispatch_time, or the learned suggested_dispatch_time in auto mode) falls within ±7 min of current time |
| Due nurture steps | Every 15 minutes |
| Dispatch retry pass (failed/late entries) | Hourly at :45 |
| Post metrics refresh + best-time learning | Daily at 04:00 UTC |
| Weekly planning | Mondays at 05:00 UTC |
| Calendar top-up (gap-fill next 3 days, non-manual brands) | Daily at 05:15 UTC |
| Evergreen recycling (opt-in per brand) | Wednesdays at 05:30 UTC |
| Autonomy check (earned auto-publish proposals) | Mondays at 05:45 UTC |
| Newsletter digest build | Daily at 06:00 UTC |
| Approval digest email (pending decisions) | Daily at 06:30 UTC |
To verify scheduling is working: check docker logs for lines starting with Dispatch result for brand.
To manually trigger dispatch for a brand: Command Center → Run Agents Now, or POST /api/scheduler/dispatch-now?brand_id=<id>.
Single-instance requirement: The scheduler is in-process and does not use a distributed lock. Do not run more than one API replica — jobs will double-fire if multiple instances are active.
Optional Cost-Control Gateway (LiteLLM + Ollama)
By default this gateway is off. The production server compose does not include it — each brand falls back to its own per-brand provider API key. Enable it only when you want centralised spend caps, model routing, or a local model tier.
Enabling the gateway
Add two services to your docker-compose.yml and set one env var on the api service:
services:
litellm:
image: ghcr.io/berriai/litellm:main-latest
container_name: litellm
restart: always
ports:
- "4000:4000"
volumes:
- ./litellm/config.yaml:/app/config.yaml
networks:
- pdp-network
ollama:
image: ollama/ollama
container_name: ollama
restart: always
ports:
- "11434:11434"
networks:
- pdp-network
On the powermarketing-api service, add:
environment:
# ... existing vars ...
- LITELLM_BASE_URL=http://litellm:4000
- LITELLM_MASTER_KEY=<your-litellm-master-key>
- DEEPSEEK_API_KEY=<your-deepseek-key>
- ANTHROPIC_API_KEY=<your-anthropic-key>
litellm/config.yaml — model tiers
litellm/config.yaml defines the model tiers routed through the gateway. A representative configuration:
model_list:
- model_name: local
litellm_params:
model: ollama/llama3.1:8b
api_base: http://ollama:11434
- model_name: standard
litellm_params:
model: deepseek/deepseek-chat
api_key: os.environ/DEEPSEEK_API_KEY
- model_name: premium
litellm_params:
model: anthropic/claude-3-5-sonnet-latest
api_key: os.environ/ANTHROPIC_API_KEY
Per-brand spend caps are derived from each brand's monthly budget setting. A per-brand LiteLLM virtual key is stored on the brand record.
Pull the local model
Run once after the Ollama container starts to download the model weights:
docker exec ollama ollama pull llama3.1:8b
Audience Ingest Keys
Each brand has an ingest_public_key and a webhook secret. These are auto-generated when the brand is created. On every container startup, any brand missing keys is backfilled automatically — the operation is idempotent and requires no manual intervention.
Admins and operators can rotate keys from Settings → Ingestion:
POST /api/brands/{id}/ingest-keys/rotate
Rotating keys immediately invalidates the previous key pair. Update any external systems that post to the ingest webhook before rotating in production.
Per-brand Resend webhook (delivery events)
Email engagement events (opens, clicks, bounces, complaints) come back from each brand's own Resend account. Because every brand has a separate Resend account with its own signing secret, the webhook endpoint is per brand:
POST /api/webhooks/email/{ingest_public_key}
In that brand's Resend account → Webhooks, point the endpoint to this URL (shown with a copy button in Settings → Ingestion), then paste Resend's signing secret into the brand's Settings → API Keys → Resend webhook secret. The signature is verified against that brand's secret; if no secret is configured the event is accepted but logged as unverified. A legacy global endpoint /api/webhooks/email remains for single-account setups.
Onboarding & Plans
Tenants are provisioned by a superadmin from inside the app: + Add brand in the brand selector, then Users → + Add User with brand access ticked. Both POST /api/brands and brand deletion are superadmin-only.
The public signup endpoint has been removed. POST /api/onboarding/signup and signup.html no longer exist — they were unauthenticated and unhardened, and onboarding is now operator-only by design.
Plan tiers and their limits:
| Plan | Monthly Budget | Seat Limit | Shared AI calls / month |
|---|---|---|---|
| Free | $5 | 1 | 100 |
| Starter | $50 | 3 | 0 — own key required |
| Pro | $500 | 10 | 0 — own key required |
Deliverability
A deliverability guardian monitors complaint and bounce events per brand. When the threshold is reached (default: 3 events within 30 days), the guardian automatically sets sending_paused on the brand, halting all outbound email.
To resume sending after resolving the underlying issue:
- Investigate and fix the cause (list hygiene, content, authentication records).
- Clear
sending_pausedon the brand (Settings → Brand Profile or directly via the API).
Check the Agent Log (/log.html) and your email provider's complaint/bounce dashboard to identify the affected contacts before re-enabling sending.
Database
Backup
docker cp powermarketing-api:/data/powermarketing.db ./backup_$(date +%Y%m%d).db
Run this from the host. The SQLite file contains all data. Schedule with cron for automated backups.
Migrations
Database migrations run automatically on every container start. They are idempotent — safe to run repeatedly. No manual migration steps required after updates.
Inspect
docker exec powermarketing-api sqlite3 /data/powermarketing.db ".tables"
docker exec powermarketing-api sqlite3 /data/powermarketing.db "SELECT username, role FROM users;"
Deleting a brand
A superadmin can permanently delete a brand and everything it owns from Settings → Brand Profile → Danger Zone (type the brand code to confirm), or via the API: DELETE /api/brands/{id}?confirm=<code>. Deletion cascades through every brand-scoped table (posts, calendar, contacts, subscribers, leads, decisions, metrics, segments, credentials, logs) and cannot be undone.
Back up first. There is no soft-delete or trash — take a database backup (above) before removing a brand you might need to recover. Users are global and survive; only their access mapping to the deleted brand is removed.
Updates
docker pull powermarketing-api:latest
docker compose up -d
Migrations run automatically on the new container start. The database volume persists across updates.
Monitoring
| Method | What to check |
|---|---|
GET /health | Container alive |
docker logs powermarketing-api | Errors, bootstrap messages, dispatch results |
/log.html | All agent runs with status and errors |
| Command Center | Overnight summary, recent activity |
CORS
PowerMarketing is a headless, multi-brand engine: each product embeds the subscribe widget (or calls the ingest API) from its own domain, and you do not know every product domain in advance — new brands bring new sites. For that reason the recommended setting is open CORS:
CORS_ORIGINS=*
* is safe here.
The two browser-reachable surfaces are both safe to call cross-origin:
- Operator API (brands, contacts, decisions, publishing…) is protected by a JWT Bearer token, not cookies — so a permissive CORS policy cannot be abused via ambient credentials (there are none to ride on).
- Public ingest (
/api/ingest/{public_key}/subscribe) is meant to be called from any product site, and is gated by the per-brandingest_public_keyplus mandatory double opt-in — the worst a stranger can do is trigger a confirmation email to an address they typed.
If you prefer to lock it down (single-brand, known domains only), set an explicit allow-list instead — but remember to add every product domain that hosts the widget, including the www. and bare-apex variants:
CORS_ORIGINS=https://marketing.planningpowertools.com,https://www.planningpowertools.com,https://yourproduct.com
Troubleshooting
"Invalid credentials" on first login
- Check docker logs for
Bootstrap: created superadmin. If missing,BOOTSTRAP_ADMIN_PASSWORDwas empty — set it and restart. - If your password contains
$: docker-compose YAML escapes$$→$. What was stored may differ from what you typed. Try with one fewer$.
Bootstrap admin not created
The DB already has users (from a previous run). Reset: docker volume rm powermarketing-data and restart (destroys all data — do this only on a fresh server).
Dispatch not running automatically
- Check
dispatch_timeis set in Settings → Automation and is in UTC. - The scheduler checks every 15 minutes within ±7 min of the configured time.
- Check docker logs for APScheduler entries.
Posts stuck in Decision Inbox (Track 1 not auto-publishing)
- Check Settings → Automation → Publication Rules — the content type must have auto-publish enabled.
- Check the brand has a credential for the target platform (Settings → Credentials).
API key errors
- Settings → Brand Profile → Test Connection confirms the LLM key works.
- API key errors appear in the Agent Log (
/log.html) and, when generating from the UI, as a clear message ("Add it in Settings → API Keys") rather than a generic error.
Video generates a script but no finished video
- Script, storyboard, and voiceover are produced by the LLM and need only a brand LLM key. Rendering an actual video file requires a video provider (HeyGen, Runway, or a generic REST provider) configured under Settings → Video Providers, each with its own API key.
- Without a provider, the Videos page will only ever show the generated script — this is expected, not a fault.
Container crashes on startup
- Check
docker logs powermarketing-apifor Python tracebacks. - Common causes: missing required env var, malformed
CORS_ORIGINS.