A small PHP / MariaDB (MySQL) to-do list with lists, tags, recurring entries and statistics.
- Create a database and load
sql/install.sql. - Copy
config.sample.phptoconfig.phpand adapt it. - Serve the directory with PHP (mysqli) behind HTTP authentication and HTTPS, see below. Or run the container.
Back up the database before every upgrade, and regularly:
mysqldump --single-transaction todo > todo-$(date +%F).sql
# restore into an empty database:
mysql todo < todo-2026-01-31.sqlThe version of the database layout is stored in the table settings (db.version). After
updating the program, upgrade the database with the migration runner:
php scripts/migrate.php --status # shows the version and the pending migrations
php scripts/migrate.php # asks for confirmation, then applies them in orderIt reads the connection settings of config.php (or TODO_CONFIG). The database user of the
application usually may not change the schema; set TODO_MIGRATE_USER and
TODO_MIGRATE_PASSWORD to an administrative account. The migrations are the numbered files in
sql/migrations/; read the comments at the top of a file before you run it, they describe what it
changes and which data would stop it. Schema changes cannot be rolled back: if a migration fails,
the statements before the failing one stay applied. Fix the data it complains about and run the
rest of the file by hand, or restore the backup and start again. What the migrations do so far:
012:recurrenceMode(an interval in days) becomes the unit (days, weeks, months, years) andrecurrenceIntervalthe number of units.013: tags whose names differ only in case are merged,recurringCopiedrows of deleted entries are removed, and the constraints (unique tag names, foreign keys, ranges of the values) and indexes are added. It stops without deleting anything if an entry belongs to a list which does not exist or a value is out of range. TheCHECKconstraints need MariaDB 10.2.1 or MySQL 8.0.16 (older servers accept but do not enforce them).014: all tables are converted toutf8mb4(emoji and other 4-byte characters) with the collationutf8mb4_unicode_520_ci. It rebuilds the tables, so run it when nobody uses the application. Tags which the new collation considers equal (likestraßeandSTRASSE) are merged.015: the start and due dates becomeDATEcolumns (a time of day of older entries is dropped).
sql/install.sql creates a new database with the layout of the latest version (and db.version);
sql/uninstall.sql removes it. The scripts of the first versions are in sql/legacy/; they cannot be
replayed (see the note there), databases older than version 11 have to be upgraded by hand.
The tests check that a database upgraded from version 11 or 12 has exactly the schema of a new installation.
They run against MariaDB 10.11 and MySQL 8.0 (with the default sql_mode, which refuses the zero date
that the first versions used as a default: the migrations 012 and 013 work around that).
A database on another host should be reached over TLS: see $db_ssl in config.sample.php.
The application has no login of its own. It relies on the web server's HTTP authentication
(REMOTE_USER) and rejects every request without it, as well as every request not made over
HTTPS ($require_https). This is done by the drop-in library in basic-auth/, which can be
reused in other small apps (see basic-auth/README.md). It is the last line of defence only,
set up the rest properly:
-
HTTPS only. Basic authentication sends the password with every request.
.htaccess(Apache) andnginx.conf.samplealready require it. TheX-Forwarded-Protoheader is never believed by them, as any client can send it; behind a proxy which terminates TLS see the notes in.htaccess, and$trust_forwarded_protoinconfig.sample.php. -
Strong password hashes:
htpasswd -B -C 12 -c <file> <user>(bcrypt). Keep the file outside of the web root. -
Limit password guessing. Basic authentication has no lockout. Use fail2ban (a filter on the
401responses of the access log, or onuser ... authentication failurein the Apache error log),mod_evasive, orlimit_reqin nginx (seenginx.conf.sample). -
All users share all data. The application does not separate the data of different HTTP-authenticated users (
TodoConstants::DefaultUserID): everyone who can log in can read, change and delete everything. -
Block internal files in the web server, not only in
.htaccess..htaccessis ignored ifAllowOverridedoes not allow it; then.git/,sql/,tests/,package*.jsonand editor backups (config.php~) would be downloadable. Copy the rules of.htaccessinto the virtual host (<Directory>/<FilesMatch>), or deploy a release without.git,tests,sql,scriptsandnode_modules.sh tests/apache-access.shchecks the rules. -
Keep
config.phpoutside of the web root and set the environment variableTODO_CONFIGto its path (SetEnv TODO_CONFIG /etc/todo/config.phpin Apache,env[TODO_CONFIG]in the php-fpm pool,fastcgi_param TODO_CONFIG ...;in nginx). -
Use a restricted database user, not
root:CREATE USER 'todo'@'localhost' IDENTIFIED BY '<password>'; GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES ON todo.* TO 'todo'@'localhost';
(
CREATE TEMPORARY TABLESis used when recurring entries are copied.) Applysql/update-*.sqlwith an administrative account. -
PHP:
expose_php = Offanddisplay_errors = Off(the application also sets the latter for its own pages).
The image runs the application with Apache and PHP as an unprivileged user (port 8080), so it
works with Docker and with rootless Docker / Podman (podman can be used instead of docker
everywhere below). The CI workflow container.yml builds it for amd64 and arm64 on every change of
the main branch and publishes it as ghcr.io/codeling/todo:latest (and :sha-<commit>); build it
yourself with docker build -t todo ..
The application only works over HTTPS and with HTTP authentication (see Security), so the container
is meant to be used behind a reverse proxy which terminates TLS (nginx, Caddy, Traefik, ...)
and overwrites X-Forwarded-Proto. Publish the port only to that proxy (127.0.0.1:8080:8080, or
an internal network), and set TODO_TRUST_FORWARDED_PROTO=true: then requests with
X-Forwarded-Proto: https count as HTTPS. Without the setting the container rejects every request as
not secure. The password is checked by the container, the proxy only passes the Authorization header
(and must not cache the responses).
The quickest start is compose.yaml (the application and MariaDB, which creates the tables from
sql/install.sql on its first start):
mkdir secrets
openssl rand -hex 24 > secrets/db_password
printf '%s' 'a long password' > secrets/todo_password # for the user "alice" (see TODO_USER)
docker compose up -d # http://127.0.0.1:8080, behind your proxyWith Caddy as the proxy: todo.example.com { reverse_proxy 127.0.0.1:8080 }.
To try it without a proxy: curl -u alice:<password> -H 'X-Forwarded-Proto: https' http://127.0.0.1:8080/.
Without compose, create the database as in Installation and start the container:
docker run -d --name todo -p 127.0.0.1:8080:8080 \
-e TODO_DB_HOST=db.example.com -e TODO_DB_PASSWORD_FILE=/run/secrets/db_password \
-e TODO_USER=alice -e TODO_PASSWORD_FILE=/run/secrets/todo_password \
-e TODO_TRUST_FORWARDED_PROTO=true \
-v "$PWD/secrets":/run/secrets:ro,Z \
--read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges:true \
ghcr.io/codeling/todo:latest| Environment variable | Meaning | Default |
|---|---|---|
TODO_DB_HOST, TODO_DB_USER, TODO_DB_NAME |
database connection ($db_host, $db_user, $db_database) |
db, todo, todo |
TODO_DB_PASSWORD or TODO_DB_PASSWORD_FILE |
password of the database user | empty |
TODO_DB_SSL |
true: TLS for the database connection ($db_ssl) |
false |
TODO_LANGUAGE |
language, a file name of lang/ without .ini |
en-US |
TODO_TRUST_FORWARDED_PROTO |
true: believe X-Forwarded-Proto ($trust_forwarded_proto, see above) |
false |
TODO_USER, TODO_PASSWORD or TODO_PASSWORD_FILE |
the user of the web interface (stored as a bcrypt hash) | required |
Prefer the ..._FILE variants (Docker / Podman secrets or mounted files) to plain passwords in the
environment; the container removes the passwords from the environment of the web server. More users,
or settings which have no variable (like $db_ssl with certificates), need files: mount a full
config.php (see config.sample.php) at /etc/todo/config.php and an htpasswd file
(htpasswd -B -C 12 -c htpasswd alice) at /etc/todo/htpasswd; they replace the variables above.
Rootless Podman and SELinux need :Z on such mounts, and the files must be readable by the user of
the container (podman unshare chown 33:33 <file> or chmod a+r).
The root file system can be read-only; only /tmp has to be writable (it holds the generated
config.php and password file). Apache logs to the output of the container.
The container itself keeps no data. Everything is in the database; with compose.yaml it is in the
named volume db (/var/lib/mysql of the db service). It survives docker compose down and
docker compose pull, but not docker compose down -v. Export and import go through SQL dumps, not
through copies of the files of the volume (those only work with the same MariaDB version). The
database user of compose.yaml has all privileges on the database todo, the password of
root is random and not needed. The commands read the password from the secret of the db service:
# export (back up before every upgrade, and regularly)
docker compose exec -T db sh -c 'mariadb-dump --single-transaction -utodo -p"$(cat /run/secrets/db_password)" todo' > todo-$(date +%F).sql
# import: replaces the tables of the dump, e.g. into a new volume
docker compose exec -T db sh -c 'mariadb -utodo -p"$(cat /run/secrets/db_password)" todo' < todo-2026-01-31.sqlIf you start a container with docker run and another database server, use mysqldump and mysql
as in Upgrading and backups.
Moving an existing database into the container:
-
Dump the old database (see Upgrading and backups):
mysqldump --single-transaction todo > todo.sql. The version of the layout must be 12 or newer (SELECT value FROM settings WHERE `key` = 'db.version'). -
Start the new setup (
docker compose up -d, as above). A new volume is created, andsql/install.sqlcreates empty tables in it. -
Import the dump with the import command above. It replaces the empty tables with the old data.
-
If the dump comes from an older version of the application than the image, upgrade the database:
docker compose run --rm todo php scripts/migrate.php --status docker compose run --rm todo php scripts/migrate.php
(with
compose.yamlthe database user may change the schema; with another database server setTODO_MIGRATE_USERandTODO_MIGRATE_PASSWORDwith-e, see Upgrading and backups). -
Open the application and check the data, then stop the old installation.
The same steps move the data back out: export it and import it into the other database.
Upgrading the image: make a backup, docker compose pull && docker compose up -d, then run
docker compose run --rm todo php scripts/migrate.php (--status shows the pending migrations); the
application needs the database of its own version, so do this right after the update.
sh tests/container.sh tests the image (access rules, database connection, no root user).
See tests/README.md. The workflows in .github/workflows run them (and test the container image), check that vendor/ matches
package-lock.json and run npm audit.