Hands-on deployment: Telegram to WordPress with Docker Compose¶
This tutorial starts with the easiest reliable path—Telegram long polling—and then shows how to switch the same stack to a public HTTPS webhook.
The finished flow is:
Telegram message
-> telegram.updates poller or HTTPS webhook
-> call-baxter Telegram plugin
-> telegram.message.new / telegram.message.edited
-> mirror rules
-> cb-wordpress-plugin
-> WordPress REST API
-> draft or published WordPress post
What the example stack runs¶
| Service | Purpose | Host exposure in the first-run polling setup |
|---|---|---|
call-baxter |
Telegram ingestion, rules, persistence, WordPress actions | none; Unix socket only |
db |
MariaDB for WordPress | none |
wordpress |
WordPress application and REST API | http://localhost:8080 by default |
caddy |
Public HTTPS Telegram webhook proxy | disabled unless the webhook profile is enabled |
Persistent state is written below docker/state/.
Deployment model used in this tutorial¶
This guide assumes the deployment host builds the image from a reviewed Git revision. The normal source-to-runtime path is:
workstation
-> git push origin main
-> deployment host: git pull --ff-only
-> verify exact Git SHA
-> docker compose build --pull call-baxter
-> docker compose up -d
This is the simplest model for the repository as it is currently structured: the Compose file has a local build: definition and the Dockerfile installs call-baxter, tg-agent-cli, and cb-wordpress-plugin together. Do not copy a locally built image to the server unless you deliberately switch to an image-registry workflow with immutable tags or digests.
On the deployment host, update and record the revision before changing containers:
For a production registry workflow later, build once in CI, push a tag based on the Git SHA, and deploy by digest instead of rebuilding independently on every host.
Sample credentials used below¶
All credentials in this block are fake examples. They are intentionally unsuitable for a real deployment. Replace every one of them before starting a public or persistent instance.
# Telegram example only; not a real usable bot token.
TGCLI_BOT_TOKEN=123456:EXAMPLE_ONLY_REPLACE_ME
# WordPress integration account created later in this tutorial.
WP_USERNAME=wp-bot
WP_APP_PASSWORD="demo wp application password change me"
# Local demo database credentials only.
WORDPRESS_DB_NAME=wordpress
WORDPRESS_DB_USER=wordpress
WORDPRESS_DB_PASSWORD=demo-db-password-change-me
WORDPRESS_DB_ROOT_PASSWORD=demo-root-password-change-me
# Publication policy used by the example.
WP_PUBLISH_STATUS=draft
WP_REQUIRED_HASHTAG="#blog"
WP_CATEGORIES=
WP_TAGS=
The sample values make the configuration shape easy to recognize; the actual setup steps below generate or obtain proper secrets instead of reusing these strings.
Choose an ingestion mode¶
| Mode | Use it when | Public DNS/HTTPS required | Compose command |
|---|---|---|---|
| Polling | first setup, local machine, private server, development | no | docker compose up -d --build |
| Webhook | public production ingress with lower delivery latency | yes | docker compose --profile webhook up -d --build |
Start with polling. Telegram does not allow getUpdates polling while a webhook is registered, so the polling steps explicitly remove any old webhook before testing.
Prerequisites¶
You need:
- Docker Engine with Docker Compose v2;
- a Telegram account;
- a Telegram bot token created with
@BotFather; - a browser for the initial WordPress setup;
curlfor the verification commands;- optional
jqfor readable JSON output.
Run all commands from the repository root unless a step says otherwise.
1. Verify the repository before deployment¶
The release gate checks tests, linting, typing, package builds, Compose configuration, Docker hardening invariants, documentation commands, and coverage floors.
2. Create a Telegram bot¶
In Telegram:
- Open a chat with
@BotFather. - Send
/newbot. - Choose a display name and a username ending in
bot. - Copy the bot token into a password manager.
- Open the new bot chat and press Start so you can send it direct test messages later.
Treat the token like a password. Anyone who has it can operate the bot API.
3. Prepare docker/.env¶
Generate three safe values:
python - <<'PY'
import secrets
print("TELEGRAM_WEBHOOK_SECRET=" + secrets.token_urlsafe(48))
print("WORDPRESS_DB_PASSWORD=" + secrets.token_hex(24))
print("WORDPRESS_DB_ROOT_PASSWORD=" + secrets.token_hex(24))
PY
Edit docker/.env and set at least the following. The values shown here retain the same fake/example shape from above; replace the Telegram and database credentials with your real generated values before starting the stack:
TELEGRAM_POLL_ENABLED=true
TGCLI_BOT_TOKEN=123456:EXAMPLE_ONLY_REPLACE_ME
WORDPRESS_DB_PASSWORD=demo-db-password-change-me
WORDPRESS_DB_ROOT_PASSWORD=demo-root-password-change-me
WP_USERNAME=wp-bot
WP_APP_PASSWORD="replace-after-wordpress-setup"
WP_PUBLISH_STATUS=draft
WP_REQUIRED_HASHTAG="#blog"
WP_CATEGORIES=
WP_TAGS=
For an actual deployment, the resulting file should contain the real BotFather token and the generated database passwords, not the demonstration strings above.
WP_CATEGORIES and WP_TAGS are comma-separated numeric WordPress term IDs. Docker supplies environment variables as strings, and Call Baxter converts these values to integers before sending them to WordPress; taxonomy names such as News are not accepted as IDs.
To retrieve category IDs without enabling the WordPress REST API, query WordPress directly from its container:
docker compose exec -T wordpress php -r 'require "/var/www/html/wp-load.php"; foreach (get_terms(["taxonomy"=>"category","hide_empty"=>false]) as $term) echo $term->term_id."\t".$term->name."\t".$term->slug."\n";'
Copy the desired term_id values into WP_CATEGORIES=4,9 (or the hashtag-specific WP_*_CATEGORY_ID variables) and restart Call Baxter.
The Call Baxter container also exposes a cb executable backed by the installed Call Baxter CLI and configured for its Unix socket by default:
docker compose exec call-baxter cb ingress runs --limit 20
docker compose exec call-baxter cb ingress reingest ing:123
WP_REQUIRED_HASHTAG accepts a single hashtag such as #blog; leave it empty
to disable required-hashtag filtering. WP_CATEGORIES and WP_TAGS accept
comma-separated WordPress term IDs such as 4,9. They are fallback terms; the
default Docker config also maps #blog and #news through
WP_BLOG_CATEGORY_ID and WP_NEWS_CATEGORY_ID.
For the first run, leave the webhook domain values as placeholders. Caddy is in an optional Compose profile and will not start in polling mode.
4. Prepare persistent directories¶
The Call Baxter container runs as UID/GID 10001:10001. Bind-mounted runtime directories must therefore be writable by that identity.
mkdir -p state/{run,data,caddy_data,caddy_config,mysql,wordpress}
sudo chown -R 10001:10001 state/run state/data
sudo chmod 0770 state/run state/data
On a rootless Docker installation, map the directory ownership to the container UID used by your runtime instead of blindly using the host UID 10001.
5. Validate the rendered Compose configuration¶
A successful command prints nothing and exits with status 0.
Inspect the service plan when diagnosing interpolation problems:
In polling mode, caddy is not part of the default service set because it belongs to the webhook profile.
6. Start MariaDB and WordPress¶
Wait until the WordPress setup page answers:
until curl --fail --silent --show-error \
"http://localhost:${WORDPRESS_PORT:-8080}/wp-admin/install.php" \
>/dev/null; do
printf 'waiting for WordPress...\n'
sleep 3
done
Open this URL in a browser:
When deploying to another machine, replace localhost with that server's address and ensure the configured WORDPRESS_PORT is reachable only from trusted networks during setup.
7. Complete the WordPress setup¶
Create the initial administrator through the WordPress setup screen.
Then create a dedicated integration user:
- Open Users → Add New.
- Use the username configured as
WP_USERNAME, for examplewp-bot. - Give it the Editor role for text-only publishing. Use a role with upload capability when mirroring Telegram media.
- Save the user.
- Open that user's profile.
- Under Application Passwords, create one named
call-baxter. - Copy the generated password immediately; WordPress displays it only once.
Update docker/.env:
The spaces in a WordPress application password are accepted. Keep the value quoted in the env file.
8. Verify WordPress REST authentication¶
Load the env file into the current shell:
Verify the credentials through the REST API:
curl --fail-with-body --silent --show-error \
--user "${WP_USERNAME}:${WP_APP_PASSWORD}" \
"http://localhost:${WORDPRESS_PORT}/wp-json/wp/v2/users/me?context=edit"
Expected result: a JSON user object for wp-bot.
Common failures:
401: wrong username or application password;403: the user lacks the required capability;- HTML instead of JSON: the WordPress installation is incomplete or the URL is wrong;
- no Application Passwords section: finish the site setup and use HTTP only for local testing; production WordPress should be served over HTTPS.
9. Start Call Baxter in polling mode¶
Before polling, remove an old Telegram webhook if this bot was previously used elsewhere:
curl --fail-with-body --silent --show-error \
--request POST \
"https://api.telegram.org/bot${TGCLI_BOT_TOKEN}/deleteWebhook" \
--data-urlencode "drop_pending_updates=false"
Build the Call Baxter image on this deployment host, then start it:
# Optional but useful for an audit trail.
DEPLOY_SHA="$(git -C .. rev-parse HEAD)"
printf 'deploying Call Baxter source revision %s\n' "$DEPLOY_SHA"
docker compose build --pull call-baxter
docker compose up -d call-baxter
docker compose ps
docker compose images call-baxter
docker compose logs --tail=150 call-baxter
The bundled docker/Dockerfile installs all three Python packages needed for this flow: call-baxter, tg-agent-cli, and cb-wordpress-plugin. The separate minimal infra/ image is not sufficient for this tutorial because it deliberately omits the WordPress plugin.
The Docker runtime config enables the telegram.updates schedule when TELEGRAM_POLL_ENABLED=true.
Verify daemon health over its Unix socket:
docker compose exec call-baxter \
cb \
--config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
health
Inspect loaded plugins, rules, and schedules:
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
plugins list
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
rules list
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
schedules list
You should see:
- the Telegram plugin;
- the WordPress plugin;
mirror-telegram-message;mirror-telegram-edit;flush-media-groups;- the enabled
telegram-defaultpolling schedule.
10. Send the first Telegram-to-WordPress post¶
Send this direct message to the bot:
The parser uses the first non-empty line as the title. Leading hashtags on that line are removed from the generated title, so the WordPress title becomes:
The remaining text becomes the post body. Because the example uses WP_PUBLISH_STATUS=draft, the post appears under Posts → Drafts.
Follow the runtime while testing:
Stop log following with Ctrl-C; the container keeps running.
11. Verify all three persistence layers¶
WordPress¶
curl --fail-with-body --silent --show-error \
--user "${WP_USERNAME}:${WP_APP_PASSWORD}" \
"http://localhost:${WORDPRESS_PORT}/wp-json/wp/v2/posts?context=edit&search=First%20Call%20Baxter%20post"
Telegram projection database¶
Call Baxter runtime records¶
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
events list --kind telegram.message.new
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
actions list --kind wordpress.mirror_telegram_message
Expected state files:
12. Test idempotent edits¶
Edit the original Telegram message instead of sending a new one:
#blog First Call Baxter post
This body was edited in Telegram and should update the existing WordPress draft.
The mirror-telegram-edit rule sends telegram.message.edited to the same mirror action. The publication mapping in wp-publications.db should update the existing WordPress post rather than create a duplicate.
13. Test publishing policy¶
The example configuration supports status routing:
To test immediate publication, send:
Use #publish only after the bot user, publication policy, and review process are ready for direct publishing. Keeping the default status as draft is safer.
14. Test a Telegram media group¶
Send an album containing two or more images. Put the caption and #blog hashtag on one album item.
The single mirror-telegram-message rule handles both ordinary messages and media-group fragments. Because the WordPress plugin is configured with media_group_wait_seconds: 45, grouped fragments are staged by chat_id + media_group_id. The heartbeat rule later runs wordpress.flush_due_media_groups and creates one assembled WordPress post.
Do not add a second broad telegram.message.new staging rule. Two rules matching the same event would invoke the mirror action twice.
Watch the staging behavior:
After at least 45 seconds plus the heartbeat interval, verify the resulting post and media library in WordPress.
15. Switch from polling to an HTTPS webhook¶
Only do this after the polling flow works.
15.1 Prepare public DNS and firewall¶
Point PUBLIC_DOMAIN to the Docker host and allow inbound TCP ports 80 and 443. Telegram must be able to reach the resulting HTTPS URL.
Update docker/.env:
TELEGRAM_POLL_ENABLED=false
PUBLIC_DOMAIN=bot.example.org
ACME_EMAIL=ops@example.org
TELEGRAM_WEBHOOK_PATH=/webhooks/telegram/main
TELEGRAM_WEBHOOK_SECRET=replace-with-generated-secret
The webhook secret must contain only characters accepted by Telegram. The secrets.token_urlsafe() command from step 3 produces a suitable value.
15.2 Recreate Call Baxter and start Caddy¶
docker compose up -d --build --force-recreate call-baxter
docker compose --profile webhook up -d caddy
docker compose ps
docker compose logs --tail=150 caddy call-baxter
Verify the public health endpoint:
15.3 Register the webhook¶
curl --fail-with-body --silent --show-error \
--request POST \
"https://api.telegram.org/bot${TGCLI_BOT_TOKEN}/setWebhook" \
--data-urlencode "url=https://${PUBLIC_DOMAIN}${TELEGRAM_WEBHOOK_PATH}" \
--data-urlencode "secret_token=${TELEGRAM_WEBHOOK_SECRET}" \
--data-urlencode 'allowed_updates=["message","edited_message"]'
Inspect Telegram's current webhook state:
curl --fail-with-body --silent --show-error \
"https://api.telegram.org/bot${TGCLI_BOT_TOKEN}/getWebhookInfo"
Expected fields include the configured URL and no persistent last_error_message.
Caddy accepts only POST requests on TELEGRAM_WEBHOOK_PATH, verifies X-Telegram-Bot-Api-Secret-Token, rewrites the request to /v1/webhooks/telegram.update, and forwards it over the shared Unix socket.
16. Upgrade the deployment¶
From the repository root:
git pull --ff-only
./scripts/verify.sh
cd docker
docker compose build --pull call-baxter
docker compose up -d call-baxter wordpress db
When using webhook mode:
Before upgrading, back up at least:
17. Backup and restore¶
Stop write-heavy services before a filesystem-level backup:
docker compose stop call-baxter wordpress db
tar --create --gzip \
--file "call-baxter-backup-$(date +%Y%m%d-%H%M%S).tar.gz" \
state/data state/mysql state/wordpress
docker compose start db wordpress call-baxter
In webhook mode, Caddy may remain up but will return an upstream error while Call Baxter is stopped. For a quiet maintenance window, stop Caddy too.
Restore only into a stopped stack and preserve directory ownership.
Reconcile interrupted WordPress media uploads¶
The deployment records every successful media upload in /srv/data/wp-publications.db. The heartbeat flush automatically reviews interrupted create attempts older than WP_ORPHAN_MEDIA_CLEANUP_AFTER_SECONDS and processes at most WP_ORPHAN_MEDIA_CLEANUP_LIMIT publications per pass.
Run a manual dry-run before an accelerated cleanup:
cat > state/data/wp-media-cleanup.json <<'JSON'
{
"state_db_path": "/srv/data/wp-publications.db",
"older_than_seconds": 0,
"limit": 50,
"dry_run": true
}
JSON
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
actions execute \
--kind wordpress.cleanup_orphan_media \
--args-file /srv/data/wp-media-cleanup.json
The cleanup first searches WordPress by the deterministic publication slug. It keeps and maps tracked media when the post exists. It deletes media only when the upload belongs to an interrupted create and no matching post exists. Repeat with "dry_run": false to apply the reviewed cleanup.
Back up state/data/wp-publications.db together with WordPress. Deleting this state database removes the evidence needed for safe reconciliation.
18. Troubleshooting decision tree¶
No WordPress post
|
+-- Did Telegram deliver an update?
| +-- polling: delete any old webhook, inspect telegram-default schedule
| +-- webhook: inspect getWebhookInfo and Caddy logs
|
+-- Did Call Baxter emit telegram.message.new?
| +-- no: inspect bot token, allowed updates, projection DB, ingress errors
|
+-- Did mirror-telegram-message match?
| +-- no: check rule loading and source/kind
|
+-- Did the action run?
| +-- skipped: message lacks #blog or chat/channel policy rejected it
| +-- failed: inspect WordPress URL, user, application password, capabilities
|
+-- Did WordPress accept the request?
+-- 401: credentials
+-- 403: role/capability or security plugin
+-- 404: wrong WP_BASE_URL or REST routing
+-- 5xx: WordPress/PHP/database logs
Polling receives nothing¶
curl --silent --show-error \
"https://api.telegram.org/bot${TGCLI_BOT_TOKEN}/getWebhookInfo"
docker compose exec call-baxter \
cb --config-file /srv/config/call-baxter.yml \
--daemon-url http+unix:///srv/run/cb.sock \
schedules list
docker compose logs --tail=250 call-baxter
Confirm that:
TELEGRAM_POLL_ENABLED=true;- no webhook URL is registered;
- the bot token is correct;
- the bot chat has been started;
- no other process is consuming
getUpdatesfor the same bot.
Webhook returns 403 forbidden¶
Confirm that:
- the registered URL exactly matches
https://${PUBLIC_DOMAIN}${TELEGRAM_WEBHOOK_PATH}; - the secret registered with Telegram matches
TELEGRAM_WEBHOOK_SECRET; - Caddy was recreated after editing
.env; - Telegram is sending the expected secret-token header.
WordPress REST authentication fails¶
curl --verbose \
--user "${WP_USERNAME}:${WP_APP_PASSWORD}" \
"http://localhost:${WORDPRESS_PORT}/wp-json/wp/v2/users/me?context=edit"
Create a fresh application password rather than reusing the normal WordPress login password.
Message is skipped¶
The default policy requires #blog. Inspect the action result and make sure the hashtag is present in either the message text or media caption.
Duplicate media-group handling¶
The example intentionally contains one new-message mirror rule and one heartbeat flush rule. If you copied an older stage-media-group.yml, remove or disable it so each Telegram update reaches wordpress.mirror_telegram_message only once.
Reset the example completely¶
This deletes all local WordPress, MariaDB, and Call Baxter state:
Then restart from step 4. Do not use this command on a deployment whose data must be preserved.
Configuration map¶
| Requirement | File or variable |
|---|---|
| Bot token | docker/.env → TGCLI_BOT_TOKEN |
| Polling versus webhook | docker/.env → TELEGRAM_POLL_ENABLED |
| Poll interval and long-poll timeout | docker/.env → TELEGRAM_POLL_* |
| Public webhook URL | PUBLIC_DOMAIN + TELEGRAM_WEBHOOK_PATH |
| Webhook verification | TELEGRAM_WEBHOOK_SECRET and docker/config/Caddyfile |
| WordPress REST credentials | WP_BASE_URL, WP_USERNAME, WP_APP_PASSWORD |
| Publication hashtag/status/category policy | docker/config/call-baxter.yml |
| Event-to-action routing | docker/rules.d/*.yml |
| Runtime and projection state | docker/state/data/ |
| WordPress content and database | docker/state/wordpress/, docker/state/mysql/ |
Security checklist before production¶
- Keep
docker/.envoutside version control and readable only by the deployment operator. - Use a dedicated WordPress integration user and application password.
- Keep
WP_PUBLISH_STATUS=draftuntil direct publishing is explicitly desired. - Rotate the Telegram token immediately if it is exposed.
- Use webhook mode only behind valid HTTPS and the secret-token check.
- Restrict the host's WordPress port or place WordPress behind a separate trusted reverse proxy.
- Back up MariaDB, WordPress files, and all SQLite state before upgrades.
- Review
docker compose configfor accidentally interpolated secrets before sharing output.
Upstream references¶
- Telegram Bot API: https://core.telegram.org/bots/api
- Telegram webhook guide: https://core.telegram.org/bots/webhooks
- WordPress REST API authentication: https://developer.wordpress.org/rest-api/using-the-rest-api/authentication/
- WordPress application-password endpoints: https://developer.wordpress.org/rest-api/reference/application-passwords/