Production¶
The reference production setup is several identical servers sharing one Redis, deployed by GitHub Actions with rolling updates and automatic rollback. Requests reach the servers either through a load balancer of your own, or straight from Cloudflare (proxied DNS records, one per server), with a Caddy container on each server terminating TLS (see Cloudflare without a load balancer).
flowchart LR
C[Clients] --> CF[Cloudflare / CDN]
CF --> LB[nginx load balancer]
LB --> A1[server 1<br/>telegram-me]
LB --> A2[server 2<br/>telegram-me]
LB --> A3[server 3<br/>telegram-me]
A1 & A2 & A3 --> R[(Redis / Valkey)]
A1 & A2 & A3 --> T[t.me]
Server preparation¶
Run deploy/bootstrap-server.sh
once on each fresh Ubuntu server, as the user GitHub Actions logs in as:
It is safe to run again. It:
- installs Docker Engine and the compose plugin from Docker's repository;
- adds the user to the
dockergroup; - adds 2 GiB of swap when there is none (
SWAP_MB=0skips it). This helps small machines such as 1 GB cloud instances; - opens port 443 in the host firewall of Oracle Cloud images, which reject everything but SSH. The cloud security list or security group still decides who may connect.
Behind a load balancer of your own, allow it to reach port 8000 and nothing
else from outside, for example with UFW:
sudo ufw default deny incoming
sudo ufw allow ssh
sudo ufw allow from 10.0.0.0/24 to any port 8000 proto tcp
sudo ufw enable
Nothing else needs to be installed: the workflow copies the compose file, the
deploy script and the .env on every deployment.
How a deployment works¶
sequenceDiagram
participant GH as GitHub Actions
participant S1 as Server 1 (canary)
participant SN as Servers 2…N
GH->>GH: CI and Guards: lint, types, tests, contract, image, integration, deploy scripts
GH->>GH: build & push linux/amd64 + linux/arm64
GH->>S1: remote-deploy.sh <image:sha-…>
S1->>S1: compose up --wait (health check), smoke test inside the container
alt healthy and smoke test passed
S1-->>GH: ok, remember image
else otherwise
S1->>S1: start previous image again
S1-->>GH: failure → rollout stops, nothing else changed
end
GH->>GH: CANARY_SOAK seconds of real traffic on server 1
GH->>S1: remote-deploy.sh --verify (same image, healthy, no restart, smoke test)
loop each other server, one at a time
GH->>SN: remote-deploy.sh <image:sha-…> (same checks)
end
GH->>GH: smoke test through the public URL
Note over GH,SN: a failure after server 1 rolls back every server that took the release
- Pushing to
mainruns CI and Guards, builds the image (latestandsha-…tags) and deployssha-…to every server listed inSERVERS_IPS, one after another (deploy/rollout.sh). deploy/remote-deploy.shwaits until the new container is healthy, then runs the smoke test (scripts/smoke_matrix.py, status codes and content of every endpoint, inside the container against live t.me, one retry). The health check alone only proves that the process answers/healthz; the smoke test proves that it serves pages through this server's configuration and Redis. When either fails, it restarts the previously deployed image (recorded in~/telegram-me/.deployed-image) and fails.- Server 1 is the canary: after it took the release it serves real traffic
for
CANARY_SOAKseconds (default 60) and is verified again: it must still run the image, be healthy, not have restarted or been killed for memory, and pass the smoke test. Only then are the other servers deployed. - Last, the smoke test runs through the public URL (
PUBLIC_URL, orhttps://PUBLIC_HOST), that is through Cloudflare and Caddy, with theCF_BYPASS_KEYheader. - When any of these checks fails after a server took the release, every
server that took it is rolled back (
remote-deploy.sh --rollback), so the fleet never stays split between two releases. - The first 2.0 deployment replaces the 1.x container (named
telegram-me, started withdocker run). If 2.0 does not become healthy, the 1.x container is started again from its image and~/env.app. - Pushing a tag
v2.0.0additionally publishes2.0.0,2.0,2tags and a GitHub release with the notes fromCHANGELOG.md.
Configuration¶
Secrets¶
| Secret | Required | Purpose |
|---|---|---|
SERVERS_IPS |
yes | Server addresses separated by spaces, e.g. 203.0.113.10 203.0.113.11. The workflow masks each address in its logs and names servers 1, 2, … |
SSH_KEY |
yes | Private key for SSH_USER |
SSH_KNOWN_HOSTS |
recommended | ssh-keyscan output for the servers; without it host keys are trusted on first use (with a warning) |
REDIS_URL |
yes | Shared Redis for all servers |
CF_BYPASS_KEY |
no | Sent as x-bypass-key by the public smoke test and health check, for a Cloudflare WAF rule that skips bot protection for them |
POW_SECRET |
with ENABLE_POW |
Signs proof-of-work challenges; at least 32 characters (POW_SECRET) |
TLS_CERT, TLS_KEY |
with PUBLIC_HOST |
Cloudflare Origin CA certificate and key (PEM) for Caddy; without them Caddy uses a self-signed certificate |
DOCKER_USERNAME, DOCKERHUB_TOKEN |
yes | Push to Docker Hub (DOCKER_PASSWORD is accepted instead of the token) |
Variables¶
Repository variables override the production .env written on every
deployment:
| Variable | Default | Notes |
|---|---|---|
ENABLE_FEED |
true |
Kept on in production for existing clients; the application default is false |
ENABLE_PREVIEWS |
true |
|
ENABLE_RATE_LIMIT, RATE_LIMIT_RPM |
false, 120 |
See Rate limit and proof of work |
RATE_LIMIT_BURST, RATE_LIMIT_SHARED |
RATE_LIMIT_RPM, true |
|
ENABLE_POW, POW_DIFFICULTY |
false, 18 |
ENABLE_POW needs the POW_SECRET secret; the deploy stops without it |
POW_CHALLENGE_TTL, POW_BIND_IP |
300, true |
|
CACHE_MODE, PARSER_MODE |
normal, full |
|
PUBLIC_HOST |
— | Public hostname, e.g. api.example.com. Set it to run Caddy on port 443 of every server (Cloudflare without a load balancer) |
FORWARDED_ALLOW_IPS |
127.0.0.1 |
Set to the load balancer address(es). With PUBLIC_HOST: 172.29.53.0/24, the stack's own network (Caddy) |
DISABLE_DOCS |
false |
|
DEPLOY_IMAGE |
docker.io/koval01/telegram-me |
Repository to deploy from |
SSH_USER |
ubuntu |
|
PUBLIC_HEALTHCHECK_URL |
— | Checked after the rollout, e.g. https://api.example.com/healthz |
PUBLIC_URL |
https://PUBLIC_HOST |
Base URL of the public smoke test after the rollout (without it and PUBLIC_HOST the test is skipped), and the app's PUBLIC_URL for /robots.txt and /sitemap.xml |
CANARY_SOAK |
60 |
Seconds server 1 serves a new release before it is verified again and the other servers follow; 0 skips the canary check |
Use the production environment in the repository settings to add required
reviewers or restrict which branches may deploy.
Cloudflare without a load balancer¶
With proxied DNS records pointing at the servers themselves, Cloudflare
connects to port 443 of each one. Set the PUBLIC_HOST variable, and every
deployment then also runs the proxy service of compose.prod.yaml: Caddy
(deploy/Caddyfile).
- Caddy terminates TLS and forwards to the API, which then listens on
127.0.0.1:8000only. - The client address comes from
CF-Connecting-IP, trusted only from Cloudflare's ranges. Caddy passes it on inX-Forwarded-For, so rate limiting and logs see real clients. - Certificate. Create an Origin CA certificate in the Cloudflare dashboard
(SSL/TLS → Origin Server) and store it as the
TLS_CERTandTLS_KEYsecrets. Then use SSL mode Full (strict). Without them Caddy serves a self-signed certificate, which works with SSL mode Full only. - Let only Cloudflare's ranges reach port 443 in the cloud firewall (security list or group), and nothing reach port 8000.
API clients are programs, not browsers. Cloudflare's bot protection (Bot
Fight Mode, the "I'm Under Attack" level, Managed Challenge rules) answers
them with a challenge page (403) instead of JSON, and that includes the
deployment's public health check. Keep it off when programs are meant to use
the API. When it stays on (a demo, say), add a WAF custom rule that skips it
for requests whose header matches a long random key, for example
any(http.request.headers["x-bypass-key"][*] eq "<key>"). Store the key as
the CF_BYPASS_KEY secret for the deployment's public smoke test and health
check, and pass it to the smoke test by hand with --header "x-bypass-key:$KEY".
Cloudflare picks one of the DNS records per connection. While one server restarts during a rolling deployment, some requests to it can fail for a few seconds.
Rolling back¶
Run the Deploy workflow manually (Actions → Deploy → Run workflow) with
the tag to go back to, for example sha-0123456789ab (every build is kept in
the registry). The same rolling procedure is used, with the same checks;
uncheck smoke only while t.me itself is down, when no release can pass.
On a single server you can also roll back by hand, to the image deployed before the last one or to any other:
bash ~/telegram-me/remote-deploy.sh --rollback
cd ~/telegram-me
APP_IMAGE=docker.io/koval01/telegram-me:sha-0123456789ab \
docker compose -f compose.prod.yaml up -d --wait
Standalone feed worker¶
To keep the feed refresher off the API processes, run the Deploy workflow with
with_worker checked: every server then starts the worker service and the
API runs with FEED_REFRESH_MODE=worker. Leader election in Redis guarantees a
single active refresher across all servers either way.
Operations¶
cd ~/telegram-me
docker compose -f compose.prod.yaml ps
docker compose -f compose.prod.yaml logs -f --since 10m api
cat .deployed-image
Logs are JSON lines in production (LOG_FORMAT=json), rotated at 5 × 50 MB.