Building a Telegram Expense Tracker Bot with Google Sheets and Docker

I recently built a personal finance tool that lets me log expenses via Telegram, automatically categorizes them, and records everything in Google Sheets.

The goal? Make budgeting fast, private, and mobile-friendly — even from a Raspberry Pi.

This post explains how to reproduce it, what I learned, and where it got interesting.

What I Built

A Telegram bot that lets me send messages like:

groceries 12.50
uber 8.90
coffee -4.00

The bot:

  • Parses the text and categorizes the expense
  • Applies budget tracking with alerts if I’m close to my monthly limit
  • Logs the expense into a Google Sheet using a service account
  • Runs on a Raspberry Pi using Docker

Technologies Used

  • Python + python-telegram-bot (v20)
  • Google Sheets API via service accounts
  • Docker (with ARM build for Raspberry Pi)
  • Poetry for Python dependency management
  • YAML configs for budgets, users, and categories
  • Makefile for workflow automation

How to Reproduce It

  • Create a Telegram bot with @BotFather
  • Generate a Google service account and share a Sheet with it
  • Configure your categories and budget limits in budgets.yaml
  • Whitelist Telegram user IDs in users.yaml
  • Mount the secrets into Docker using bind volumes
  • Run it on a Raspberry Pi (with a lightweight ARM-compatible image)

Interesting Challenges

Dynamic Configuration Reloading

I wanted to update users.yaml without restarting the bot. To do that:

  • I implemented an in-memory cache with a TTL
  • It reloads configs every few seconds if the file changes
  • Updates deployable via make deploy-users over SSH

Smart Expense Parsing

  • Messages like "food -3.50" are parsed with regex
  • I used word-boundary matching to avoid false positives (e.g., “car” in “carbonara”)
  • Negative values allow for reimbursements

Secret Management in Docker

  • Rather than baking credentials into the image, I mounted them at runtime
  • Paths are resolved dynamically based on the users.yaml file location
  • This made it secure and flexible across environments

Custom Budgets Per User

  • Each user can define their own monthly budgets per category
  • If none is defined, the app falls back to the shared config
  • Budget overages trigger alerts at 25%, 50%, and 75%

Local vs. Docker Consistency

I ensured:

  • All paths in users.yaml are relative and resolved based on file location
  • The same users.yaml works in local dev and in Docker on the Pi
  • Deployment is as easy as: make build-arm && make deploy && make run-on-pi

Ideas for the Future

  • Zero-shot category classification using AI instead of static keywords
  • Multi-user dashboards with shared budgets
  • Voice input or receipt photo parsing

TL;DR

I built a Telegram bot that logs categorized expenses into Google Sheets, tracks budgets, and runs on a Raspberry Pi — using clean Python, YAML configs, and Docker. It was a great blend of DevOps, API integration, and practical problem-solving.

Automate the creation of repositories

One thing that comes across as particularly cumbersome when you are at the very early start of creating a new python project is to create the structure of the repository.

It always looks the same and never is the most interesting part of the project:

.
├── Makefile
├── README.md
├── your_project_name_in_snake_case
│   ├── __init__.py
│   └── main.py
├── poetry.toml
├── pyproject.toml
└── tests
    ├── __init__.py
    └── test_dummy.py

The good news is that it can be automated!

The objective is to create the structure using a bash script, wrapped up into a bash command accessible globally.

I am showing you how.

Objective

Whenever you want to init the structure for a new python project, you want the solution to work as follow:

  1. Move to the root of the new project;

  2. There, hitting a create repo kind of command that creates the whole structure (README.md, Makefile, .gitignore, etc.) for you when triggered.

Workflow

(1) Create the .gitignore files and templates for the files to be created:

E.g. you can find a template for the .gitignore on the Internet (you can even ask ChatGPT).

(2) Create a bash script to coordinate the creation of the different elements:

I called mine .create-poetry-repo.sh. It contains a set of usual instructions.

For instance, it creates a tests/ folder:

mkdir tests/
touch tests/__init__.py

and populates it with a minimal test suit:

cat > tests/test_dummy.py << EOF
def test_dummy():
    assert True
EOF

(3) The script is configurable, it asks the user via a prompt for the project’s name:

echo -n "project name (camel-case): "
read project_name_camel_case

This collected variable is then reused across the script e.g. to create the README.md.

cat > README.md << EOF
# $project_name_camel_case
EOF

For some use cases, the variable must be converted into snake case first:

project_name_snake_case=${project_name_camel_case//-/_}

It can the be used – e.g. to create the Makefile:

mkdir $project_name_snake_case
touch $project_name_snake_case/__init__.py

(4) The logic (bash script and template files such as the .gitignore) are then stored on a single source of truth i.e. a Github repository;

More can be done. Once you are satisfied with the logic you have encapsulated, you can upload your .sh script and your templates (e.g. the .gitignore file) on a Github repository.

Usage

Now, whenever you want to create a new python project:

(1) Download the documents (e.g. .gitignore) and bash script from the remote Github repository using the curl command:

    zsh> curl -OL <local_filename> https://raw.githubusercontent.com/<user>/<project>/<branch>/<path_to_remote_filename>

Notes:

  • after the .sh script has been downloaded, you must change the set of permissions to execute it. chmod +x <executable_filename> usually does the job.
  • if the visibility of your github repo is set on private, you will have to use a personal token after you have created it from the developer settings: github.com/settings/tokens; then use it in the arguments of the curl command:
curl -H "Authorization: token $GITHUB_API_REPO_TOKEN" \
     -H "Accept: application/vnd.github.v3.raw" \
     -O \
     -L https://raw.githubusercontent.com/<user>/<project>/<branch>/<path_to_remote_filename>
  • the token will be globally accessible from your zsh terminals if stored in the ~/.zshrc file:
export GITHUB_API_REPO_TOKEN="your_github_token"

(2) Execute the bash script:

    zsh> chmod +x .create-poetry-repo.sh
    zsh> ./.create-poetry-repo.sh

(3) To make it even more flexible, wrap the logic within a globally reusable command stored on ~/.zshrc:

    alias create_poetry_project='curl -H "Authorization: token $GITHUB_API_REPO_TOKEN" \
      -H "Accept: application/vnd.github.v3.raw" \
      -O \
      -L https://raw.githubusercontent.com/<user>/<repo>/<branch>/.create-poetry-repo.sh && chmod +x .create-poetry-repo.sh && ./.create-poetry-repo.sh'
    alias cpp="create_poetry_project"

(4) Do not forget to apply the changes via:

    zsh> source ~/.zshrc

Now, whenever I am in the root of a new project, I just have to execute:

zsh> cpp
Project name (camel-case):

…for the structure to be automatically created!