I've been a todo.txt user for years. The format is dead simple: one task per line, plain text, priorities and projects marked with a single character prefix. It works with any editor, syncs fine with anything that can move a file, and will outlast every app that ever tried to replace it.
The problem is I want to be able to add tasks from my phone, check things off from whatever machine I'm on, and have it all go to the same file. The answer everyone reaches for is a sync service, but I didn't want to hand my task list to Dropbox or iCloud. So I wrote a small web app instead: todo.yttrx.com.
The source is at github.com/waffle2k/todotxt-web. It's a Flask app with a terminal-style UI, user authentication, a REST API, a CLI client, and an MCP server so Claude can manage my tasks. This post covers how it runs in production.
The app runs on a Linux VPS sitting behind Cloudflare. The stack is simple: Docker Compose runs the app container, nginx terminates TLS and proxies to it, and Certbot manages the Let's Encrypt certificate.
I use Cloudflare as a DNS proxy. That means all traffic hits Cloudflare's edge first, and nginx only sees Cloudflare's IP ranges rather than real client IPs. To get real IPs in logs, nginx is configured to trust Cloudflare's ranges and read the CF-Connecting-IP header:
# /etc/nginx/conf.d/cloudflare-real-ip.conf real_ip_header CF-Connecting-IP; real_ip_recursive on; set_real_ip_from 173.245.48.0/20; set_real_ip_from 103.21.244.0/22; set_real_ip_from 103.22.200.0/22; set_real_ip_from 103.31.4.0/22; set_real_ip_from 104.16.0.0/13; set_real_ip_from 104.24.0.0/14; set_real_ip_from 108.162.192.0/18; set_real_ip_from 131.0.72.0/22; set_real_ip_from 141.101.64.0/18; set_real_ip_from 162.158.0.0/15; set_real_ip_from 172.64.0.0/13; set_real_ip_from 188.114.96.0/20; set_real_ip_from 190.93.240.0/20; set_real_ip_from 197.234.240.0/22; set_real_ip_from 198.41.128.0/17; set_real_ip_from 2400:cb00::/32; set_real_ip_from 2606:4700::/32; set_real_ip_from 2803:f800::/32; set_real_ip_from 2405:b500::/32; set_real_ip_from 2405:8100::/32; set_real_ip_from 2a06:98c0::/29; set_real_ip_from 2c0f:f248::/32;
This goes in conf.d/ so it applies globally to all virtual hosts.
The app is packaged as a Docker image published to the GitHub Container Registry. The production docker-compose.yml in /root/todotxt-web/ looks like this:
version: '3.8'
services:
todo-app:
container_name: todo-txt-manager
image: ghcr.io/waffle2k/todotxt-web:latest
ports:
- "5001:5000"
volumes:
- ./todo_data:/app/data
environment:
- FLASK_ENV=production
- FLASK_APP=app.py
- TODO_FILES_DIR=/app/data
- SECRET_KEY=${SECRET_KEY:-please-change-this-secret-key-in-production}
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/login"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
A few things worth noting here.
The container exposes port 5000 internally, but on the host I bind it to 5001 (5001:5000). That's just so the port number doesn't collide with anything else running on the box -- nginx proxies to 5001 regardless of what the container calls it internally.
User data and todo.txt files live in ./todo_data/, which is a directory on the host bind-mounted into the container. This means the data persists across container restarts and image upgrades. The directory structure is straightforward:
/root/todotxt-web/todo_data/
users.json # account credentials (hashed)
todo_waffles.txt # one file per user
users.json looks like this:
{
"waffles": {
"username": "waffles",
"email": "waffles@yttrx.com",
"password_hash": "<sha256>",
"created_at": "2025-06-30T16:29:41.252371",
"last_login": "2026-05-07T16:44:06.851099"
}
}
One entry per user, keyed by username. Passwords are stored as SHA-256 hashes. Each user gets their own todo_username.txt file in the same directory.
The secret key is read from the environment so it never appears in the compose file. Before starting the container I export it in the shell:
export SECRET_KEY=your-actual-secret-key docker compose up -d
To start, stop, or upgrade:
# Start docker compose up -d # View logs docker compose logs -f # Upgrade to latest image docker compose pull docker compose up -d # Stop docker compose down
Nginx terminates TLS and proxies all traffic to the container. The site config lives at /etc/nginx/sites-available/todo and is symlinked into sites-enabled/:
server {
listen 80;
listen [::]:80;
server_name todo.yttrx.com;
location /.well-known/acme-challenge/ { allow all; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name todo.yttrx.com;
ssl_certificate /etc/letsencrypt/live/todo.yttrx.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/todo.yttrx.com/privkey.pem;
access_log /var/log/nginx/todo.access.log;
error_log /var/log/nginx/todo.error.log;
location / {
proxy_pass http://127.0.0.1:5001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
}
The HTTP server block does two things: passes ACME challenge requests through (needed for cert renewal) and redirects everything else to HTTPS. The HTTPS block proxies all requests to 127.0.0.1:5001 -- the port the container is listening on.
The X-Forwarded-Proto header tells Flask it's running behind a proxy. Without it, Flask generates HTTP links in redirects even when the client is on HTTPS.
To enable the site and reload nginx:
ln -s /etc/nginx/sites-available/todo /etc/nginx/sites-enabled/todo nginx -t systemctl reload nginx
Certbot issues and renews the certificate. The first time, with nginx already running and the HTTP server block in place:
certbot --nginx -d todo.yttrx.com
Certbot modifies the nginx config to add the certificate paths and installs a systemd timer for automatic renewal. Nothing else to manage.
The web UI is fine for checking things off from a phone, but I live in the terminal. The CLI client is at cli/todo.py in the repo. It talks to the same API the web UI uses, and it's compatible with the standard todo.txt command set.
To install it:
git clone https://github.com/waffle2k/todotxt-web.git pip install -r todotxt-web/cli/requirements.txt cp todotxt-web/cli/todo.py ~/bin/todo chmod +x ~/bin/todo
Then set three environment variables -- I put these in my shell profile:
export TODO_URL=https://todo.yttrx.com export TODO_USER=youruser export TODO_PASS=yourpassword
Or if you prefer a credentials file, drop something like this in ~/.todo.env and source it from your profile:
# ~/.todo.env -- do not commit TODO_URL=https://todo.yttrx.com TODO_USER=youruser TODO_PASS=yourpassword
# in ~/.bashrc or ~/.zshrc [ -f ~/.todo.env ] && set -a && source ~/.todo.env && set +a
Basic usage matches what you'd expect from todo.sh:
# List open tasks todo ls # Add a task todo add "(A) Write blog post about todo.txt +writing @computer" # Mark it done todo do 12 # Set a priority todo pri 7 B # Remove priority todo depri 7 # List tasks for a project todo ls +writing # List @contexts todo lsc # Edit a task in place todo replace 4 "Updated task description +project" # See open vs. done counts todo report
The -p flag strips color for scripts or piping. -v gives verbose output -- it'll tell you the task ID after adding, how many tasks are in the list, and so on.
# Plain output, verbose todo -pv ls # Pipe into grep todo -p ls | grep homelab
I also set up an MCP server so Claude can read and manage my task list. MCP (Model Context Protocol) is how Claude talks to external tools -- think of it as a way to give Claude access to an API through structured function calls.
The server is at mcp/server.py in the repo. To install it:
pip install -r todotxt-web/mcp/requirements.txt
Configure it the same way as the CLI -- three environment variables. Then add it to Claude's config file (~/.claude/settings.json for Claude Code, or claude_desktop_config.json for Claude Desktop):
{
"mcpServers": {
"todotxt": {
"command": "python",
"args": ["/path/to/todotxt-web/mcp/server.py"],
"env": {
"TODOTXT_WEB_URL": "https://todo.yttrx.com",
"TODOTXT_USER": "youruser",
"TODOTXT_PASS": "yourpassword"
}
}
}
}
Once that's in place, Claude can list tasks, add new ones, complete them, or edit them. It's useful for things like "add a task for all the things we just talked about" or "what's on my plate for the homelab project."
The MCP integration turns out to be more useful than I expected. I've been experimenting with a "secondbrain" setup -- a directory of Markdown files that I maintain alongside my conversation context in Claude Code. Each file covers a project, a running set of notes, or a topic I'm thinking through. The files live at ~/secondbrain/ and Claude can read them as part of the working context for any session.
Having the todo MCP wired in alongside that means Claude can do things like: extract action items from a notes file and add them as tasks, close tasks when work described in those notes is complete, or create a new task for a follow-up while we're in the middle of working through something. It's not magic -- it's just that the todo list and the working context are in the same place at the same time, which means less friction switching between them.
The list_tasks tool accepts filters for project, context, priority, and completion status, so I can ask Claude things like "what's left on the homelab project" and get back only the relevant tasks. The todo.txt format's +project and @context markers map directly to those filters, so the structure I already put into task descriptions becomes queryable without any extra work.
The data path is:
browser / CLI / MCP server
|
v
nginx (TLS termination, port 443)
|
v
Docker container (Flask, port 5001 on host)
|
v
./todo_data/ (bind mount, persists on host)
The docker-compose.yml pulls from GitHub Container Registry, so upgrades are a docker compose pull && docker compose up -d. The todo_data directory stays untouched between upgrades.
It's not complicated. A todo list shouldn't be.