A long-running Docker container that, on a schedule, picks a random entry from a local quotes.json, fetches the matching image from a Backblaze B2 bucket, publishes it to Threads, and then removes both the entry from the JSON and every version of the image from B2.
Each entry in quotes.json must include at least image (the object key in the B2 bucket) and caption (the post text). All other fields are ignored.
- One container, started once, kept alive by
restart: unless-stopped. - Inside it, supercronic fires
python -m threads_posteron a schedule defined incrontab. - Schedule times are interpreted in the container's timezone, set via
TZindocker-compose.yml(default:Asia/Kolkata). - One post per cron firing. Default schedule = every 90 minutes from 09:30 to 21:30 IST (9 posts/day: 09:30, 11:00, 12:30, 14:00, 15:30, 17:00, 18:30, 20:00, 21:30).
ghcr.io/cosmicpush/threads-api-posting:latest
CI pushes three tags on every push to master:
latest— the most recent master buildsha-<short>— pinned to commitmaster-YYYYMMDD-HHMMSS— pinned to build time
Built for linux/amd64 and linux/arm64.
Assume VPS user ubuntu, working directory /home/ubuntu/threads-api-posting/.
mkdir -p /home/ubuntu/threads-api-posting
cd /home/ubuntu/threads-api-posting
# Fetch only the compose file from the repo (you don't need the rest on the VPS)
curl -fsSLO https://raw.githubusercontent.com/cosmicpush/threads-api-posting/master/docker-compose.yml
# Place quotes.json inside a 'data' subdirectory (the directory, not the
# file, is what gets bind-mounted — single-file bind mounts break atomic
# rewrites of quotes.json).
mkdir -p data
touch data/quotes.json
# The container runs as uid 10001 internally — give it write permission
sudo chown -R 10001:10001 data
# Create .env (see "Configuration" below)
nano .env# === Threads Graph API (required) ===
THREADS_ACCESS_TOKEN=
THREADS_USER_ID=
# === Backblaze B2 (required) ===
B2_BUCKET=12amstories
B2_KEY_ID=
B2_APPLICATION_KEY=
B2_PREFIX=threads/
# === Host directory containing quotes.json (required for the bind mount) ===
# Must be a directory (not a single file) so atomic file replacement works.
QUOTES_HOST_DIR=/home/ubuntu/threads-api-posting/data
# === Tuning (optional) ===
THREADS_MEDIA_WAIT_SECONDS=30
THREADS_PRESIGN_EXPIRATION_SECONDS=900
# === Telegram notifications (optional) ===
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=You do not need to set QUOTES_JSON_PATH, LOCKFILE_PATH, or TZ in .env — docker-compose.yml sets them.
docker compose pull # fetch :latest from GHCR
docker compose up -d # start the container detached
docker compose logs -f # follow the logsThe container is now alive. Supercronic will fire python -m threads_poster at every scheduled time and you'll see each run in the logs.
If GHCR access requires login (e.g. the package is private):
echo "$GITHUB_TOKEN" | docker login ghcr.io -u cosmicpush --password-stdin# Trigger a post immediately (one-shot, doesn't disturb the daemon)
docker compose exec poster python -m threads_poster
# View recent logs
docker compose logs --tail=200 poster
# Update to a newer image after a master push
docker compose pull && docker compose up -d
# Stop the container (will not auto-restart until you `up` again)
docker compose down
# Edit the schedule — change `crontab` in the repo, push to master,
# then on the VPS:
docker compose pull && docker compose up -dEdit crontab in this repo:
# every two hours instead of every hour
30 9,11,13,15,17,19,21 * * * python -m threads_poster
# or run only at 10:30 and 18:30
30 10,18 * * * python -m threads_posterPush to master → CI builds a new image → on the VPS docker compose pull && docker compose up -d.
Edit TZ: in docker-compose.yml. Any IANA timezone (Asia/Kolkata, UTC, America/New_York, …) works.
docker build -t threads-poster:dev .
QUOTES_HOST_PATH=$(pwd)/quotes.json \
docker compose -f docker-compose.yml up -d…or override the image temporarily with image: threads-poster:dev in compose.
- Acquire a lockfile (
/var/lock/threads_poster/...) — concurrent firings exit silently. - Pick a random entry from
quotes.json. - HEAD the entry's image in B2. Missing? Prune the stale entry and exit non-zero.
- Generate a presigned URL, create a Threads media container, wait, publish.
- Remove the entry from
quotes.json(atomic write via temp file +os.replace+fsync). - Purge every B2 version of the image via
list_object_versions+delete_objectswith eachVersionId— bypasses the lifecycle rule entirely so no ghost data lingers.
.
├── .github/workflows/docker-publish.yml # CI: build + push to GHCR on master
├── Dockerfile # multi-stage; pulls supercronic, installs deps
├── crontab # supercronic schedule
├── docker-compose.yml # deploy unit for the VPS
├── requirements.txt
├── threads_poster/ # Python source
│ ├── __init__.py
│ ├── __main__.py
│ ├── b2_storage.py
│ ├── config.py
│ ├── main.py
│ ├── quotes_store.py
│ └── threads_api.py
└── temp/ # previous non-Docker implementation