How stats.pphat.top is deployed and operated: first-time server setup,
Cloudflare Tunnel, automatic deploys from GitHub, everyday operations,
rollback and troubleshooting.
For image internals and the full list of environment variables, see docker-build.md.
Contents
- Architecture
- Prerequisites
- First-time server setup
- Cloudflare Tunnel setup
- Automatic deploys (GitHub Actions)
- Day-to-day operations
- Rollback
- Secondary deployments
- Troubleshooting
- Security checklist
┌──────────────────────── server (Docker Compose) ────────────────────────┐
Visitor / GitHub camo │ │
│ │ ┌─────────────┐ localhost:${PORT} ┌──────────┐ ┌─────────┐ │
▼ │ │ cloudflared │ ──────────────────▶ │ app │ ───▶ │ redis │ │
Cloudflare edge ◀───┼── │ (outbound │ shares app's │ Node.js │ │ cache + │ │
stats.pphat.top │ │ only) │ network │ cluster │ │ limits │ │
│ └─────────────┘ └────┬─────┘ └─────────┘ │
│ │ 127.0.0.1:3102 (host only) │
└────────────────────────────────────────────┼─────────────────────────────┘
▼
optional host nginx / local curl
| Piece | Role |
|---|---|
| Cloudflare edge | Terminates TLS for stats.pphat.top and sends requests into the tunnel. |
cloudflared |
Opens an outbound connection to Cloudflare, so the server needs no open inbound ports. It forwards requests to the app over the app container's own network. |
app |
The API (node dist/server-cluster.js, one worker per CPU). It listens on PORT inside the container. |
redis |
Response cache and rate-limit counters shared by all workers. Not reachable from outside. |
| Database | Either SQLite in the app-data volume or Cloudflare D1, set by DATABASE_PROVIDER. |
| GitHub Actions | Builds the image, pushes it to GHCR and redeploys the server over SSH on every push to latest. |
There are two separate ports:
PORT(from.env) is what the app listens on inside the container, and whatcloudflaredforwards to..env.examplesets3102; ifPORTis unset it defaults to3000.3102on127.0.0.1is the host-side port, always 3102. Use it forcurlon the server or the host nginx. It is never exposed publicly.
| Domain | Runs on | Role |
|---|---|---|
stats.pphat.top |
This Docker deployment (Cloudflare Tunnel) | Primary |
stats1.pphat.top, stats.pphat.me |
Other self-hosted instances | Fallbacks used by nginx/default.conf |
github-stats-3hu8.onrender.com |
Render (render.yaml) |
Fallback |
githubstats.up.railway.app |
Railway | Fallback |
stats.pphatdev.workers.dev |
Cloudflare Workers (wrangler.toml) |
Edge worker |
- A Linux server (x86_64) with at least 1 GB RAM and outbound internet access. No inbound ports are needed.
- Docker Engine 24+ with the Compose plugin (
docker compose version). - Git, plus read access to this repository.
- A Cloudflare account with
pphat.topon Cloudflare DNS, and access to Zero Trust. The Free plan is enough. - A GitHub personal access token for the app (
GITHUB_TOKEN). A classic token with no scopes is enough for public data. Without one, GitHub allows only 60 API requests per hour.
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER" # log out and back in afterwards
docker compose versionsudo mkdir -p /var/www && sudo chown "$USER" /var/www
cd /var/www
git clone https://github.com/pphatdev/github-stats.git
cd github-stats
git switch latest # the branch the deploy workflow pullsThe server must stay on
latest. The deploy workflow runsgit pull --ff-only origin latest, which fails on any other branch or if files were edited on the server.
cp .env.example .env
sed -i "s/^SERVER_SALT=.*/SERVER_SALT=$(openssl rand -hex 32)/" .env
nano .envSet at least:
| Variable | Value |
|---|---|
GITHUB_TOKEN |
Your GitHub token |
SERVER_SALT |
Filled in by the sed above. Keep it stable. Changing it resets visitor de-duplication. |
CLOUDFLARED_TUNNEL_TOKEN |
Leave the placeholder for now. You'll paste the real token in section 4. |
DATABASE_PROVIDER |
sqlite (local file in a Docker volume) or cloudflare (D1, which needs the CLOUDFLARE_* values) |
CLOUDFLARED_TUNNEL_TOKEN must never be empty. Compose refuses to run
any command, even ones that don't start cloudflared, if it is.
docker compose up -d --build app redis
docker compose ps # app should become "healthy" within ~30 s
curl http://localhost:3102/health # {"status":"healthy", ...}If curl fails, see Troubleshooting before going on.
- Go to dash.cloudflare.com → Zero Trust → Networks → Tunnels. Some dashboards call it Networks → Connectors → Cloudflare Tunnels.
- Create a tunnel → Cloudflared, name it
github-stats, then Save. - On the install screen, choose Docker and copy only the token, the
long
eyJ…string after--token. Don't run the command it shows.
cd /var/www/github-stats
nano .env # CLOUDFLARED_TUNNEL_TOKEN=eyJ... (no quotes, no spaces)
docker compose up -d --force-recreate cloudflared
docker compose logs --tail=20 cloudflaredYou should see four Registered tunnel connection connIndex=0..3 lines, and the
tunnel shows HEALTHY in the dashboard.
cloudflared already forwards every request to the app
(--url http://localhost:${PORT} in compose.yaml). Cloudflare still needs a
DNS record that sends stats.pphat.top to this tunnel:
-
In the tunnel, open Public Hostname (on some dashboards: Routes / Published application routes) → Add a public hostname:
Field Value Subdomain statsDomain pphat.topPath (empty) Type HTTPURL localhost:<PORT from .env>, for examplelocalhost:3102 -
Save. Cloudflare creates the DNS record (a proxied CNAME to
<tunnel-id>.cfargotunnel.com).- If it says "A record with that host already exists", go to
pphat.top → DNS → Records, delete the
statsrecord, and save again.
- If it says "A record with that host already exists", go to
pphat.top → DNS → Records, delete the
A hostname route set in the dashboard replaces the --url fallback for that
hostname, so its URL must use the same port as PORT in .env.
curl -sI https://stats.pphat.top/health | head -1 # HTTP/2 200Workflow: .github/workflows/deploy.yml
Triggers: every push to latest, except commits that only change *.md,
docs/** or issue templates. You can also run it by hand from Actions →
Deploy → Run workflow.
What it does:
- build: builds the image for
linux/amd64and pushesghcr.io/pphatdev/github-stats:latestand:sha-<7-char-commit>. - deploy (GitHub environment
production), over SSH:cd $DEPLOY_PATH git pull --ff-only origin latest docker login ghcr.io … APP_IMAGE=ghcr.io/pphatdev/github-stats:sha-<commit> docker compose pull app docker compose up -d --no-build --remove-orphans # waits up to 150 s for the app healthcheck; the job fails if it isn't healthy docker logout ghcr.io && docker image prune -f
Repository → Settings → Secrets and variables → Actions → New repository secret:
| Secret | Required | Value |
|---|---|---|
SSH_HOST |
Yes | Server IP or hostname |
SSH_USER |
Yes | Deploy user. Must be in the docker group. |
SSH_KEY |
Yes | Private key, see below |
DEPLOY_PATH |
Yes | /var/www/github-stats |
SSH_FINGERPRINT |
Strongly recommended | Server host key fingerprint, see below. Without it the deploy can't detect a man-in-the-middle. |
SSH_PORT |
No | Defaults to 22 |
Create a dedicated deploy key, run on the server as the deploy user:
ssh-keygen -t ed25519 -f ~/.ssh/github_deploy -N "" -C "github-actions-deploy"
cat ~/.ssh/github_deploy.pub >> ~/.ssh/authorized_keys
cat ~/.ssh/github_deploy # → secret SSH_KEY (whole file)
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub # → secret SSH_FINGERPRINT (the SHA256:… part)
rm ~/.ssh/github_deploy # the private key now lives only in GitHubOptional: under Settings → Environments → New environment →
production, add Required reviewers so every deploy waits for your
approval.
Image visibility: if the GHCR package github-stats is private, the
workflow's built-in GITHUB_TOKEN can still pull it during deploy. To pull it
by hand, docker login ghcr.io with a token that has read:packages.
git switch latest
git merge <your-branch>
git push origin latest # triggers the deployFollow the run under Actions. If it fails, the job log shows the last
docker compose ps output and the app's health status.
On the server:
cd /var/www/github-stats
git pull --ff-only origin latest
docker compose up -d --build # builds from source instead of pulling from GHCRAll commands run in /var/www/github-stats.
| Task | Command |
|---|---|
| Status and health | docker compose ps |
| App logs | docker compose logs -f --tail=100 app |
| Tunnel logs | docker compose logs -f --tail=50 cloudflared |
| Local health check | curl http://localhost:3102/health |
| Public health check | curl -sI https://stats.pphat.top/health |
| Restart the app | docker compose restart app |
Apply .env changes |
docker compose up -d --force-recreate |
| Recreate the tunnel only | docker compose up -d --force-recreate cloudflared |
| Clear the response cache | docker compose exec redis redis-cli FLUSHDB (also resets rate-limit counters) |
| Resource usage | docker stats --no-stream |
| Disk cleanup | docker image prune -f |
Changing .env: Compose only reads .env when it creates a container,
so restart is not enough. Use --force-recreate.
After recreating app: cloudflared shares the app container's network.
If app is recreated on its own (up -d app), recreate cloudflared too, or
the tunnel stops working (error 1033 or 502). A plain docker compose up -d
handles this automatically.
- SQLite (
DATABASE_PROVIDER=sqlite):Restore by stoppingdocker compose cp app:/app/data/stats.db ./backup-stats-$(date +%F).dbapp, copying the file back withdocker compose cp, then startingapp. - Cloudflare D1 (
DATABASE_PROVIDER=cloudflare):npx wrangler d1 export stats --remote --output=backup.sql. .env: keep a copy in your password manager. It holds every secret for the deployment.
Redis only holds cache and rate-limit counters, so it doesn't need a backup.
Fastest: pin the previous image on the server. Every deploy is tagged
sha-<commit>, and git log --oneline gives you the commit.
cd /var/www/github-stats
export APP_IMAGE=ghcr.io/pphatdev/github-stats:sha-<previous-commit>
docker compose pull app
docker compose up -d --no-buildThat pin lasts only until the next deploy. To make the rollback permanent,
revert the bad commit on latest and push, and CI redeploys automatically:
git revert <bad-commit> && git push origin latestThese run independently of the Docker deployment and act as fallbacks.
An alternative front door that doesn't use the tunnel. nginx terminates TLS
and proxies to 127.0.0.1:3102. If the local app returns 429/5xx or is down,
it tries stats1.pphat.top, then stats.pphat.me, Render, Railway, and
finally returns 503.
Using it requires:
- a DNS A record for the domain pointing at the server (instead of the tunnel CNAME);
- ports 80/443 open;
- a Let's Encrypt certificate at the path in the config. It currently
references
/etc/letsencrypt/live/stats.sophat.top/; adjust it per server.
sudo cp nginx/default.conf /etc/nginx/sites-available/github-stats
sudo ln -s /etc/nginx/sites-available/github-stats /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginxBlueprint deploy: Render → New → Blueprint, then choose this repo. It runs
node dist/index.js (single process). In the Render dashboard, set:
GITHUB_TOKEN, SERVER_SALT, APP_ENV=production, plus either REDIS_URL
or REDIS_ENABLED=false. Free instances sleep when idle, so the first request
is slow.
Connect the repo in Railway. It detects the Dockerfile automatically. Set the
same variables as for Render.
npx wrangler login
npx wrangler secret put GITHUB_TOKEN
npx wrangler deploy # production (name: stats)
npx wrangler deploy --env developmentThe worker uses D1 (stats) and two KV namespaces, which are already bound in
wrangler.toml.
Start with these three checks. They tell you which layer is broken:
docker compose ps # 1. are containers up and healthy?
curl -s http://localhost:3102/health # 2. does the app answer locally?
curl -sI https://stats.pphat.top/health | head -1 # 3. does it answer publicly?| Symptom | Cause | Fix |
|---|---|---|
curl: (7) Failed to connect to localhost port 3102 |
No container running, or app is crash-looping. |
docker compose ps, then docker compose logs --tail=50 app. |
App logs: SERVER_SALT must be at least 16 chars / SERVER_SALT is required when APP_ENV=production |
Salt missing or too short. | Set it in .env (openssl rand -hex 32), then docker compose up -d --force-recreate app. |
App logs: Invalid environment variables (another field) |
Bad value, e.g. APP_ENV=prod. |
Fix the named variable. APP_ENV/NODE_ENV must be development, production or test. |
required variable CLOUDFLARED_TUNNEL_TOKEN is missing a value |
Token is empty in .env. |
Put the real token, or any placeholder, in .env. |
.env change has no effect |
The container wasn't recreated. | docker compose up -d --force-recreate. |
| Public: Error 1033 (HTTP 530) | DNS points to a tunnel with no connected cloudflared. |
Check docker compose logs cloudflared, then use the rows below. |
cloudflared: Unauthorized: Tunnel not found |
The token belongs to a tunnel that was deleted or recreated. | Get the token of an existing tunnel (Zero Trust → Tunnels → Configure), put it in .env, recreate cloudflared, and make sure the stats DNS record points at that tunnel (4.3). |
cloudflared: Provided Tunnel token is not valid |
Placeholder or mangled token. | Paste the token again, with no quotes, spaces or line breaks. |
Public: empty HTTP 503; cloudflared: No ingress rules were defined |
Tunnel connected but has no route, and compose.yaml lacks the --url fallback. |
Update compose.yaml (git pull) and recreate cloudflared, or add the Public Hostname in the dashboard (4.3). |
| Public: HTTP 502 / Cloudflare "Bad gateway" | Tunnel reaches the container but nothing answers on the target port. | The dashboard route URL must be localhost:<PORT>, matching .env. Also check app is healthy. If app was recreated alone, recreate cloudflared too. |
| Public: HTTP 429 | Per-IP rate limit hit. GitHub's image proxy shares a few IPs across all README viewers. | Raise RATE_LIMIT_STRICT_MAX / RATE_LIMIT_MAX in .env and recreate app. |
Deploy job fails at git pull --ff-only |
The server isn't on latest, or tracked files were edited there. |
On the server: git status; git checkout -- . to drop local edits; git switch latest. |
Deploy job: Host key verification failed / fingerprint mismatch |
SSH_FINGERPRINT is wrong, or the server was rebuilt. |
Re-run ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub on the server and update the secret. |
| Deploy job: app never healthy | The new version fails to start. | docker compose logs app on the server; roll back. |
unable to open database file |
The app-data volume is owned by root, from an older image. |
docker compose down, then docker run --rm -v github-stats_app-data:/d alpine chown -R 1000:1000 /d, then docker compose up -d. |
| Badges/cards show stale numbers | Response cache. | Wait for the TTL or clear it: docker compose exec redis redis-cli FLUSHDB. |
-
.envexists only on the server (it is git-ignored and excluded from the image), with permissionschmod 600 .env. -
SERVER_SALTis random, at least 32 hex characters, and backed up. - No inbound ports open except SSH (
sudo ufw allow OpenSSH && sudo ufw enable). Ports 80/443 are only needed if you use the host nginx. - SSH: key-only login (
PasswordAuthentication no), and the deploy user is not root. -
SSH_FINGERPRINTsecret set, so deploys verify the host key. - The
productionenvironment has required reviewers if more people can push tolatest. -
GITHUB_TOKEN(the app's) has no scopes beyond what public data needs. - The Cloudflare tunnel token has only been shared with this server. If it leaks, rotate it in Zero Trust → Tunnels → Configure.
Built into the setup, so nothing to do: the app runs as a non-root user with all Linux capabilities dropped; port 3102 is bound to loopback only; Redis has no published port; rate limits are shared across workers; and SVG responses carry a sandboxing Content-Security-Policy.