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.

Environment variables in Docker

To pass environment variables to a container via a Dockerfile, you have 3 main methods:

(1) Using the -e flag:

docker run -e MY_VAR1="foo" -e MY_VAR2="fii" my_docker_image

(2) Using a .env file:

docker run --env-file=.env my_docker_image

(3) Mounting a volume:

docker run -v /path/on/host:/path/in/container my_docker_image

Let’s explore the 3 main methods to pass environment variables to a container via Dockerfile.

To perfectly understand, I pre-baked a short example you can glance over in the pre-requisite section that we will reuse in the following sections.

Pre-requisite

You have the following structure:

    .
├── Dockerfile
└── scripts
│   └── create_file.sh

The `create_file.sh“ bash script contains the following lines:

#!/bin/bash

set -e

cat> dummy.txt <<EOL
here is my first var: $MY_VAR1
and here my second one: $MY_VAR2.
EOL

and the Dockerfile is as follow:

FROM python

WORKDIR /app
COPY . /app

COPY --chmod=755 ./scripts/create_file.sh /app/scripts/create_file.sh

CMD /app/scripts/create_file.sh && cat dummy.txt

Using the -e flag

zsh> docker build -t my_docker_image .

zsh> docker run -e MY_VAR1="foo" -e MY_VAR2="fii" my_docker_image
here is my first var: foo
and here my second one: fii.

Note: the variables are replaced at run time, i.e. in the processes launched by the CMD command. Should you run the aforementioned script in a RUN instruction (i.e. during the build time), the variables would not have been replaced. See the below example:

FROM python

WORKDIR /app
COPY . /app

COPY --chmod=755 ./scripts/create_file.sh /app/scripts/create_file.sh

RUN /app/scripts/create_file.sh

CMD cat dummy.txt

The above image would have rendered the following once executed:

zsh> docker build -t my_docker_image .

zsh> docker run -e MY_VAR1="foo" -e MY_VAR2="fii" my_docker_image
here is my first var:
and here my second one: .

It could be however cumbersome to pass on all your variables in a command line, especially when you have multiple environment variables. It can then become handy to rather use an environment file.

Using an .env file

You can achieve the same results as previously. Simply add a .env file in your root project containing the following lines:

MY_VAR1="foo"
MY_VAR2="fii"

You should now have the following structure:

.
├── Dockerfile
├── scripts
│   └── create_file.sh
└── .env

Then, simply run:

zsh> docker run --env-file=.env  my_docker_image
here is my first var: "foo"
and here my second one: "fii".

Note: You most always want to .gitignore the content of the .env file.

Mounting volumes

Sometimes you want to share files stored on your host system directly with the remote container. This can be useful for instance in the case where you want to share configuration files for a server that you intend to run on a Docker container.

Via this method, you can access directories from the remote.

So, let’s say you have the following architecture:

.
├── Dockerfile
├── conf
│   └── dummy.txt
└── scripts
│   └── create_file.sh

The content of the text file is as follow:

here is my first var: "foo"
and here my second one: "fii".

And the Dockerfile contains the following lines:

FROM python

WORKDIR /app
COPY . /app

CMD cat /conf/dummy.txt

You can therefore see the outcome:

zsh> docker build -t my_docker_image .

zsh> docker run -v /relative/path/project/conf:/conf my_docker_image
here is my first var: "foo"
and here my second one: "fii".

Should you change the content of the dummy.txt file on the host, the outcome would also be changed while running the image in the container without you needing to build the image again:

zsh> docker run -v /relative/path/project/conf:/conf my_docker_image
here is my first var: "fuu"
and here my second one: "faa".

Note: A container is a running instance of an image. Multiple containers can derive from an image. An image is a blueprint, a template for containers, containing all the code, libraries and dependencies.

You should be now ready to go!