This guide deploys the repository on a fresh Ubuntu server with:
- Docker Engine + Compose plugin
- one internal application container
- one public Caddy container for HTTPS and reverse proxy
- automatic Let's Encrypt certificates through Caddy
- persistent application data in Docker volumes
The resulting public topology is:
caddyexposed on80/443scamscreenerinternal only/api/v1/healthand/api/v1/metricsblocked publicly by Caddy
Production now uses:
Before you start, make sure:
- your domain already points to the Ubuntu server
- ports
80and443are reachable from the internet - you have SMTP credentials for admin MFA and password-reset mail
- you can log in to the server via SSH
Use a fresh Ubuntu LTS server.
Recommended minimum:
- 2 vCPU
- 2 GB RAM
- 20 GB SSD
Create DNS records for your domain:
Arecord to the server IPv4AAAArecord if you use IPv6
Verify from your own machine:
dig +short scamscreener.creepans.net
dig +short AAAA scamscreener.creepans.netThe output must point to your server.
SSH into the server:
ssh root@YOUR_SERVER_IPUpdate the system:
apt update
apt upgrade -y
DEBIAN_FRONTEND=noninteractive apt install -y ca-certificates curl gnupg git iptables-persistentUsing a dedicated deploy user is cleaner than operating everything as root.
adduser scamscreener
usermod -aG sudo scamscreener
usermod -aG docker scamscreener 2>/dev/null || trueIf you want to continue as that user after Docker is installed, reconnect later with:
ssh scamscreener@YOUR_SERVER_IPKeep your current SSH session open while applying rules. If you use a non-standard SSH port, replace 22 below.
IPv4 rules:
iptables -F INPUT
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -p tcp --dport 80 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j ACCEPT
iptables -P INPUT DROP
iptables -P FORWARD ACCEPT
iptables -P OUTPUT ACCEPT
netfilter-persistent saveIf the server uses public IPv6, mirror the rules with ip6tables:
ip6tables -F INPUT
ip6tables -A INPUT -i lo -j ACCEPT
ip6tables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
ip6tables -A INPUT -p tcp --dport 22 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 80 -j ACCEPT
ip6tables -A INPUT -p tcp --dport 443 -j ACCEPT
ip6tables -P INPUT DROP
ip6tables -P FORWARD ACCEPT
ip6tables -P OUTPUT ACCEPT
netfilter-persistent saveNotes:
- do not set
FORWARDtoDROP, because Docker relies on packet forwarding - if your cloud provider has its own firewall or security group, open
80and443there as well
Set up Docker from the official repository:
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-pluginVerify:
docker --version
docker compose versionIf you created a deploy user:
usermod -aG docker scamscreenerThen reconnect as that user, or run:
newgrp dockerCreate a stable application directory and hand it to the deploy user:
sudo mkdir -p /srv/scamscreener
sudo chown -R scamscreener:scamscreener /srv/scamscreener
sudo chmod 750 /srv/scamscreenerUpload the repository contents from your local machine via SFTP or FTP into:
/srv/scamscreener
Then continue on the server as the scamscreener user:
cd /srv/scamscreenerUpload the project contents, not a nested extra folder layer. After upload, the server should contain files like:
/srv/scamscreener/docker-compose.yml
/srv/scamscreener/Dockerfile
/srv/scamscreener/Caddyfile
/srv/scamscreener/scripts/update.py
/srv/scamscreener/scripts/reset.py
/srv/scamscreener/app
/srv/scamscreener/css
/srv/scamscreener/sites
Set script permissions once after upload:
cd /srv/scamscreener
chmod 750 scripts/*.sh
chmod 750 scripts/*.pyCopy the provided production example:
cp .env.production.example .env.productionEdit it:
nano .env.productionSet at least these values:
CADDY_SITE_ADDRESS=scamscreener.creepans.net
TRAINING_HUB_ENV=production
TRAINING_HUB_PUBLIC_BASE_URL=https://scamscreener.creepans.net
TRAINING_HUB_ENFORCE_HTTPS=true
TRAINING_HUB_ADMIN_MFA_REQUIRED=true
TRAINING_HUB_PASSWORD_RESET_SEND_EMAIL=true
TRAINING_HUB_SESSION_BIND_USER_AGENT=true
TRAINING_HUB_RETENTION_AUTO_ENABLED=true
TRAINING_HUB_SMTP_HOST=smtp.example.com
TRAINING_HUB_SMTP_PORT=587
TRAINING_HUB_SMTP_USERNAME=YOUR_SMTP_USERNAME
TRAINING_HUB_SMTP_PASSWORD=YOUR_SMTP_PASSWORD
TRAINING_HUB_SMTP_FROM_EMAIL=no-reply@scamscreener.creepans.net
TRAINING_HUB_SMTP_USE_STARTTLS=true
TRAINING_HUB_SMTP_USE_TLS=false
TRAINING_HUB_SITE_PROJECT_CLASSIFICATION=Private non-commercial community project
TRAINING_HUB_SITE_OPERATOR_NAME=YOUR_LEGAL_NAME_OR_ENTITY
TRAINING_HUB_SITE_POSTAL_ADDRESS=YOUR_SERVICEABLE_POSTAL_ADDRESS
TRAINING_HUB_SITE_CONTACT_CHANNEL=YOUR_PUBLIC_CONTACT
TRAINING_HUB_SITE_PRIVACY_CONTACT=YOUR_PRIVACY_CONTACT
TRAINING_HUB_SITE_HOSTING_LOCATION=Ashburn, Virginia, USAOptional:
TRAINING_HUB_ADMIN_USERNAMES=your-admin-username
TRAINING_HUB_SECRET_KEY=YOUR_OWN_LONG_RANDOM_SECRET
WEB_CONCURRENCY=2
MARKETGUARD_LOWESTBIN_RATE_LIMIT_PER_MINUTE=30
TRAINING_HUB_API_DOCS_ENABLED=false
MARKETGUARD_API_DOCS_ENABLED=falseImportant notes:
- if
TRAINING_HUB_SECRET_KEYis omitted, the app generates one on first boot and persists it in the app data volume - if
TRAINING_HUB_ADMIN_USERNAMESis omitted, the default bootstrap admin username isadmin - keep
TRAINING_HUB_TRUSTED_PROXIES=127.0.0.1unless you intentionally know you need extra proxy ranges; Docker Compose appends the internal Caddy IP automatically /impressumand/datenschutzrender from theTRAINING_HUB_SITE_*variables/docs,/redoc, and/openapi.jsonshould remain disabled on public production unless you have an explicit internal-access requirement- if you operate the site publicly in Germany or the EU, a pseudonym or Discord handle alone is likely not sufficient for the provider-identification fields;
scripts/preflight.shwarns about obviously incomplete values but cannot replace legal review
Lock down the file permissions:
chmod 600 .env.productionBefore the first deployment, run:
bash scripts/preflight.shThis checks:
- required files exist
- required production environment values exist
- Caddy domain and public base URL match
- SMTP transport encryption is configured sanely
- Compose resolves successfully
Build and start everything:
python3 scripts/update.pyThis starts:
scamscreeneras the internal FastAPI appcaddyas the public reverse proxy with automatic HTTPS
Internally the update script does:
- optional preflight validation
docker compose build --pulldocker compose up -d --remove-orphans- wait for the app container health check
- print final service state
Only Caddy is exposed publicly.
Check service state:
docker compose psYou want:
scamscreenerstatushealthycaddystatusrunning
Check logs:
docker compose logs --tail=100 scamscreener
docker compose logs --tail=100 caddyYou do not want:
- Python tracebacks
- Caddy ACME errors
- settings validation failures
From your own machine, check:
curl -I https://scamscreener.creepans.net
curl -I https://scamscreener.creepans.net/hub
curl -I https://scamscreener.creepans.net/api/v1/lowestbin
curl -I https://scamscreener.creepans.net/api/v2/lowestbin
curl -I https://scamscreener.creepans.net/api/v1/health
curl -I https://scamscreener.creepans.net/api/v1/metrics
curl -I https://scamscreener.creepans.net/docsExpected:
- main site responds with
200,303, or similar valid app response lowestbin v1responds with200and includesDeprecation: truelowestbin v1includesSunset: Mon, 01 Jun 2026 00:00:00 GMTlowestbin v2responds with200healthandmetricsrespond with403from public networksdocsresponds with404unless you intentionally enabled API docs
If TRAINING_HUB_ADMIN_USERNAMES was not set, the first allowed admin username is:
admin
If you set TRAINING_HUB_ADMIN_USERNAMES, the first account must use one of those names.
After startup:
- open
https://scamscreener.creepans.net/hub - register the first admin account
- log in
- complete the admin MFA flow via email
After the first admin works, verify:
- login works
- admin MFA mail arrives
- password-reset mail arrives
- uploads work
lowestbin v1works publicly and shows deprecation headerslowestbin v2works publicly- admin area loads
- backup creation works
When you want to deploy a new version with FTP/SFTP:
- upload the changed repository files to
/srv/scamscreener - do not overwrite
.env.productionunless you intentionally changed it - on the server run:
cd /srv/scamscreener
python3 scripts/update.pyIf you only changed static configuration and want to skip base-image pulls:
cd /srv/scamscreener
python3 scripts/update.py --skip-pullIf you intentionally want to delete the full deployment state and start from zero, run:
cd /srv/scamscreener
python3 scripts/reset.pyThe script asks for the exact confirmation phrase before it proceeds. It removes:
- containers in the production compose stack
- Docker volumes for app data
- Docker volumes for Caddy certificates and config
Optional full local image cleanup:
cd /srv/scamscreener
python3 scripts/reset.py --prune-imagesIf you want to skip the interactive prompt explicitly:
cd /srv/scamscreener
python3 scripts/reset.py --yes --prune-imagesAfter a reset, upload the desired release if needed and start again with:
cd /srv/scamscreener
python3 scripts/update.pydocker compose logs -f scamscreener
docker compose logs -f caddydocker compose restart scamscreener
docker compose restart caddydocker compose downDo not add -v unless you intentionally want to delete the persistent data volumes.
You should keep two layers of backups:
- application-level backups from the admin UI
- Docker volume / host-level backups
Relevant volumes:
scamscreener_datacaddy_datacaddy_config
Relevant app data inside the app container:
/app/data
Because you deploy via FTP/SFTP, rollback means re-uploading the last known-good application files and redeploying.
Recommended rollback workflow:
- keep a dated local archive of each uploaded release
- if a new release breaks, re-upload the last known-good release files
- run:
cd /srv/scamscreener
python3 scripts/update.py --skip-pullBecause app state is kept in Docker volumes, rolling back code does not remove your application data.
Check:
- DNS points to the server
- ports
80and443are reachable - no other service is already using
80or443 - your cloud firewall allows inbound
80/443
Check:
TRAINING_HUB_PUBLIC_BASE_URLuseshttps://- Caddy is running
- you did not remove the internal trusted proxy configuration
Check:
TRAINING_HUB_SMTP_HOSTTRAINING_HUB_SMTP_PORTTRAINING_HUB_SMTP_USERNAMETRAINING_HUB_SMTP_PASSWORDTRAINING_HUB_SMTP_FROM_EMAIL- only one of
TRAINING_HUB_SMTP_USE_TLSorTRAINING_HUB_SMTP_USE_STARTTLSistrue
Check:
TRAINING_HUB_ADMIN_USERNAMEScontains the intended bootstrap username- if you left it unset, use username
admin
Run:
cd /srv/scamscreener
bash scripts/preflight.shThis will usually tell you exactly which required setting is missing or inconsistent.
Most likely:
- not all files were uploaded
- the upload created an extra nested directory
Caddyfileordocker-compose.ymlwas not replaced consistently
Check:
cd /srv/scamscreener
ls -la
find scripts -maxdepth 1 -type fThen re-upload the full release and run:
bash scripts/preflight.shFor a healthy production server, the final state should be:
- the Ubuntu host exposes only
80/443 - Caddy terminates TLS publicly
- the application container is not exposed directly
TRAINING_HUB_ENV=production- admin MFA is enabled
- password-reset mail is enabled
lowestbin v1is public, deprecated, and emits the planned sunset datelowestbin v2is publichealthandmetricsare blocked publiclydocs,redoc, andopenapi.jsonare not exposed publicly unless explicitly enabled