Get ATXbbs running on a fresh Ubuntu droplet in about an hour. v0.1, last updated 2026-04-19.
apt update && apt install -y python3 python3-venv python3-pip nginx \
sqlite3 certbot python3-certbot-nginx rclone git ufw
ufw allow OpenSSH && ufw allow "Nginx Full" && ufw enable
mkdir -p /opt/springbbs
cd /opt/springbbs
python3 -m venv venv
venv/bin/pip install --upgrade pip
venv/bin/pip install flask gunicorn werkzeug markdown bleach
Copy these from a release tarball (or current austinspring.com source):
/opt/springbbs/
├ app.py # main Flask app (~1,500 lines)
├ templates/
│ ├ base.html # outer chrome, nav, flash messages
│ ├ conference.html # live conference view
│ ├ thread.html # live thread + reply form
│ ├ new_thread.html # start-a-topic form
│ ├ signup.html / login.html / welcome.html
│ ├ settings.html / forgot.html / reset.html
│ ├ search.html / recent.html / members.html
│ ├ profile.html / admin.html
│ └ conference_welcome.html # host-editable welcome (planned)
├ static/
│ └ spring.css # phosphor CRT theme
├ .env # secrets (see Step 4)
└ venv/
Create /opt/springbbs/.env (chmod 600):
SPRINGBBS_SECRET=<generate with: python3 -c "import secrets; print(secrets.token_urlsafe(48))">
SPRINGBBS_SECURE=1 # set to 1 in production for SECURE cookies
# Resend HTTPS API for outbound mail (DigitalOcean blocks SMTP)
RESEND_API_KEY=re_xxxxxxxxxxxxx
RESEND_FROM='Sysop <terry@yourdomain.com>'
RESEND_REPLY_TO=terry@yourdomain.com
cd /opt/springbbs
venv/bin/python3 -c "from app import init_db; init_db()"
# Create your sysop account (in-DB; can also be done via signup then promote):
sqlite3 spring.db "
INSERT INTO users (username, password_hash, joined_at, last_seen, is_admin)
VALUES ('terry', '<hash>', datetime('now'), datetime('now'), 1);
"
# Generate the password hash:
venv/bin/python3 -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('your-password-here'))"
cat > /etc/systemd/system/springbbs.service << 'EOF'
[Unit]
Description=ATXbbs gunicorn
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/springbbs
EnvironmentFile=/opt/springbbs/.env
ExecStart=/opt/springbbs/venv/bin/gunicorn \
--workers 1 --threads 4 --max-requests 200 \
--bind 127.0.0.1:8920 \
--access-logfile /var/log/springbbs-access.log \
--error-logfile /var/log/springbbs-error.log \
app:app
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload && systemctl enable --now springbbs
cat > /etc/nginx/sites-available/yourdomain.com << 'EOF'
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/yourdomain.com;
index index.html;
location /bbs/live/ {
proxy_pass http://127.0.0.1:8920/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ $uri.html =404;
}
}
EOF
ln -s /etc/nginx/sites-available/yourdomain.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx
certbot --nginx -d yourdomain.com -d www.yourdomain.com --redirect
# Configure rclone for B2:
rclone config # follow prompts; create remote named "b2"
# Set up nightly cron jobs:
cat > /etc/cron.d/atxbbs-backup << 'EOF'
15 3 * * * root rclone sync /var/www/yourdomain.com/ b2:yourbucket/yourdomain.com/
20 3 * * * root rclone copy /opt/springbbs/ b2:yourbucket/springbbs/ --exclude 'venv/**'
EOF
If you're reviving an old yapp BBS from Wayback Machine archives:
# 1. Discover all archived thread URLs
python3 wayback-gather.py --domain spring.net > cdx.json
# 2. Fetch each thread at its latest captured timestamp
python3 fetch-from-wayback.py --cdx cdx.json --out /opt/atxbbs-tools/archive/threads/
# 3. Synthesize conference indexes from cached threads
python3 synthesize-conf-indexes.py /opt/atxbbs-tools/archive/threads/
# 4. Render to static HTML
python3 build-thread-pages.py --all
python3 build-conference-pages.py
systemctl restart springbbs
journalctl -u springbbs -n 50
# 1. Add to CONFERENCES dict in /opt/springbbs/app.py
# 2. Add to CONFERENCES dict in build-conference-pages.py
# 3. Create empty index in conferences-union/<slug>.html
# 4. Run build-conference-pages.py to render the static landing
# 5. Restart Flask
sqlite3 /opt/springbbs/spring.db \
"UPDATE users SET is_admin=1 WHERE username='handle';"
Sysop opens https://yourdomain.com/bbs/live/admin, finds the row, fills in a reason, clicks delete. Posts hide from public views; data preserved for undo. (Or via SQL: UPDATE archive_comments SET deleted_at=datetime('now'), deleted_reason='spam' WHERE id=?;)
# Restore live HTML
rclone sync b2:yourbucket/yourdomain.com/ /var/www/yourdomain.com/
# Restore Flask app + DB
rclone copy b2:yourbucket/springbbs/ /opt/springbbs/
# Or restore from a dated snapshot
rclone lsd b2:yourbucket-snapshots/ # list available dates
rclone copy b2:yourbucket-snapshots/atxbbs-2026-04-15/ /tmp/restore/
fail2ban on SSHSPRINGBBS_SECRET is unique and 48+ bytes| Symptom | Likely cause + fix |
|---|---|
HTTP 502 on /bbs/live/* | Flask down. systemctl status springbbs; check /var/log/springbbs-error.log for syntax errors after edits. |
| Welcome email not sending | RESEND_API_KEY not in env, or domain not verified at Resend, or User-Agent missing (Cloudflare blocks default Python UA — set explicit UA). |
| Conference shows fewer threads than expected | Conference index file missing topic numbers. Re-synthesize: python3 synthesize-conf-indexes.py <conf> then build-thread-pages.py <conf>. |
| CSRF errors on form submit | Page cached without fresh token. Hard refresh (Ctrl-F5) and resubmit. |
| Rate-limited 429 unexpectedly | Restart Flask to clear in-memory state, or adjust per-route limits in rate_limit() calls. |