There are two ways to run the site:
/ask page and no web admin page (you review on GitHub instead)./ask and the admin page, in Docker. Needs a paid VPS or platform.every 3 hours ┌──────────────────── Newsroom workflow ────────────────────┐
│ restore state → download results → run the newsroom → open │
│ one pull request per approved draft → save state │
└───────────────────────────────────────────────────────────┘
you merge a pull request to publish it, close it to reject it
on merge ┌──────────────────── Deploy site workflow ─────────────────┐
│ validate every article (no claim without a source) → │
│ build static pages → publish to GitHub Pages │
└───────────────────────────────────────────────────────────┘
content/articles/*.json on main. A review PR adds
one file, so you can edit the wording in the PR before merging.newsroom-state branch as a single commit that’s replaced on every run, so it doesn’t
grow. Your repository is public, so that branch is public too: it holds drafts, rejected
drafts and run logs, all built from public news.TOUCHLINE_LIVE to true.What I verified in the build sandbox: the full flow with a local stand-in for GitHub.
AUTO_PUBLISH committed straight to main.actionlint reports no problems in the workflows.What I could not do from the sandbox: run it on GitHub itself. Your first run (step 6 below) is the real test.
main the default branchMy work is on the branch claude/touchline-takes-newsroom-bqvz5d, which GitHub made the
default. The workflows expect main.
On GitHub, open your repository, go to Settings → General → Default branch, click the
✏️ next to the branch name, rename it to main and confirm.
In Settings → Actions → General → Workflow permissions:
In Settings → Pages → Build and deployment → Source, choose GitHub Actions.
In Settings → Secrets and variables → Actions:
Variables tab:
| Name | Value | |
|---|---|---|
TOUCHLINE_LIVE |
true |
the on switch |
LLM_PROVIDER |
offline |
free template writer (default). anthropic needs the secret below and costs money per article. |
AUTO_PUBLISH |
false |
true skips your review and commits articles straight to main |
RESULTS_SEASONS |
2425 2526 2627 |
optional, this is the default |
RSS_FEEDS |
comma-separated feed URLs | optional, default is BBC Sport football. Check each feed’s terms. |
Secrets tab:
| Name | |
|---|---|
FOOTBALL_DATA_API_KEY |
free key from football-data.org, for fixtures, predictions and previews. Optional but recommended. |
ANTHROPIC_API_KEY |
only if LLM_PROVIDER=anthropic |
Edit the A record with name @ (currently “Parked”) so its value is
185.199.108.153. Then add three more A records, all with name @:
| Type | Name | Value |
|---|---|---|
| A | @ | 185.199.109.153 |
| A | @ | 185.199.110.153 |
| A | @ | 185.199.111.153 |
Edit the CNAME record named www so its value is hummus03.github.io (it currently
says touchlinetakes.com).
Optional, for IPv6: add AAAA records with name @ and values 2606:50c0:8000::153,
2606:50c0:8001::153, 2606:50c0:8002::153 and 2606:50c0:8003::153.
Leave NS, SOA, _domainconnect and _dmarc alone, and don’t use GoDaddy’s “Connect
Domain” button.
Then, on GitHub, go to Settings → Pages → Custom domain, enter touchlinetakes.com
and click Save. Once the DNS check passes (minutes to a few hours), tick Enforce
HTTPS.
It’s also worth verifying the domain, which stops anyone else from claiming it on GitHub
Pages. Go to your GitHub profile’s Settings → Pages → Add a domain and add the TXT
record it shows you at GoDaddy.
main).
It takes about 3 minutes.newsroom-review, with the
article, its sources, what the Fact-checker and Editor sent back, and how to review.Each merge triggers Deploy site, and the article is live a minute or two later. After that, the newsroom runs by itself every 3 hours.
TOUCHLINE_LIVE to false.main is protected by a branch rule that requires pull requests,
AUTO_PUBLISH=true can’t push to it. Keep review mode in that case.These need you, a machine with normal internet access, or both:
RESULTS_SEASONS).FOOTBALL_DATA_API_KEY, free tier) for real
fixtures, predictions and previews. Without it the site simply has no previews.ANTHROPIC_API_KEY, or an OpenAI-compatible endpoint). The template
writer works without one, but reads robotically.PHOTO_TAGGER=false until it’s retrained on real photos (it
was trained on synthetic scenes).touchlinetakes.com DNS records.| File | Purpose |
|---|---|
Dockerfile |
One image for the site and the scheduler. Python 3.13, non-root user, one persistent volume at /data, healthcheck on /healthz. |
docker/entrypoint.sh |
Modes: web (gunicorn), scheduler (pipeline every SCHEDULE_MINUTES), all (both, for hosts with one container per disk). Any other argument runs as a command. |
docker-compose.yml + deploy/Caddyfile |
Single-VPS setup: web, scheduler and Caddy, which provides automatic HTTPS. |
deploy/production.env.example |
Every production setting, documented. Copy it to .env.production (git-ignored). |
touchline_takes/preflight.py |
The production checks. They run at startup and on demand. |
Everything persistent lives in /data:
What production mode (TOUCHLINE_ENV=production, the image default) changes:
SECRET_KEY or ADMIN_PASSWORDSITE_URLALLOW_SAMPLE_DATA=truerobots.txt, which keeps crawlers out of /admin/ask and failed admin logins are rate-limited per client IP./ask uses an in-memory index
synced from the database.Generate secrets with:
python -c "import secrets; print(secrets.token_hex(32))"
A small VPS is enough: 2 vCPU, 4 GB RAM, about 10 GB disk. The image is about 1.9 GB, mostly ChromaDB’s ONNX runtime, JAX, SciPy and pandas. This is the setup I tested end to end in the build sandbox: Compose with Caddy on HTTPS, with the scheduler publishing a story that the web container then served.
Install Docker on the server, then:
git clone https://github.com/hummus03/Touchline-Takes.git && cd Touchline-Takes
cp deploy/production.env.example .env.production
Fill in .env.production: SECRET_KEY, ADMIN_PASSWORD, FOOTBALL_DATA_API_KEY,
ANTHROPIC_API_KEY, RESULTS_SEASONS. Then check it before starting anything:
docker compose build
docker compose run --rm web python -m touchline_takes.preflight
The only remaining error should be the results data, which is downloaded on first start
when RESULTS_SEASONS is set. To fetch it now instead:
docker compose run --rm web python scripts/download_football_data.py --league E0 --seasons 2425 2526 2627
Point DNS at the server: an A record (and AAAA if you have IPv6) for
touchlinetakes.com and www.touchlinetakes.com. Then start everything:
docker compose up -d
Caddy gets a Let’s Encrypt certificate automatically once DNS resolves to the server.
www redirects to the bare domain. Check it:
docker compose ps
docker compose logs -f scheduler
curl https://touchlinetakes.com/healthz
Reviewing drafts: AUTO_PUBLISH=false is the default. Each scheduler run pauses at
human review. Approve or reject drafts at https://touchlinetakes.com/admin (user admin,
your ADMIN_PASSWORD), or from the server:
docker compose exec web python -m touchline_takes.review list
These platforms attach a disk to one machine, so web and scheduler must share a
container. Use the image’s default all mode, and scale to exactly one instance:
SQLite doesn’t support multiple hosts. Set PROXY_HOPS=1 because the platform’s proxy
sits in front.
I couldn’t test these two from the build sandbox (no accounts). The settings below follow each platform’s documented Docker and volume setup; check them against the current docs when you deploy.
Fly.io, fly.toml:
app = "touchline-takes"
primary_region = "lhr"
[env]
SITE_URL = "https://touchlinetakes.com"
PROXY_HOPS = "1"
RESULTS_SEASONS = "2425 2526 2627"
PHOTO_TAGGER = "false"
[[mounts]]
source = "touchline_data"
destination = "/data"
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = "off" # the scheduler must keep running
min_machines_running = 1
[[http_service.checks]]
method = "GET"
path = "/healthz"
interval = "30s"
timeout = "5s"
grace_period = "60s"
fly launch --no-deploy
fly volumes create touchline_data --size 5
fly secrets set SECRET_KEY=... ADMIN_PASSWORD=... FOOTBALL_DATA_API_KEY=... LLM_PROVIDER=anthropic ANTHROPIC_API_KEY=...
fly deploy
fly certs add touchlinetakes.com
Render: create a Web Service from the repo with runtime Docker, 1 instance, a
Disk mounted at /data, health check path /healthz, and the variables from
deploy/production.env.example plus PROXY_HOPS=1. Then add the custom domain in
Render’s settings.
/healthz. It returns 200 when the app and
the database answer.Backups: everything lives in /data. Take a consistent SQLite copy while running:
docker compose exec web python -c "import sqlite3; s=sqlite3.connect('/data/touchline_takes.db'); d=sqlite3.connect('/data/backup.db'); s.backup(d)"
Then copy /data/backup.db and /data/checkpoints.db off the server.
Updates: run the following. Running containers receive SIGTERM; the scheduler finishes its current run, then exits cleanly.
git pull && docker compose up -d --build
LLM_MAX_CALLS_PER_RUN caps uncached LLM calls per run. Responses are cached
in the database.WEB_CONCURRENCY=2 the effective /ask
limit is up to twice the setting. Fine for a small site; use a CDN or WAF limit if
traffic grows.ADMIN_AUTH_RATE_LIMIT), that IP is locked out for the window even with the
correct password.