A blog for two people who write together. Phoenix, LiveView and SQLite in one Docker image. One volume holds everything: the database, the pictures, the videos. No external service, no tracker, no account anywhere else.
Two people can have the same entry open. One writes the text while the other watches it arrive, moves tiles in the gallery, sets the tags, and takes over the writing with one click.
It is meant to stay small. A part of this blog is right when nothing is left to take away.
Status: ready for a real site. Run a version tag, not latest, and keep a
copy of /data.
- One container, your data. SQLite and the uploads in
/data. Move that directory and you moved the blog. - Two writers, one entry. Only the body text is locked, and softly. Tiles, tags and settings stay open for both.
- Your Markdown, byte for byte. What you typed is what is stored, so a version diff shows real edits and nothing else.
- Pictures and videos on your own server. Scaled and converted here, played from here. No external player.
- Nothing loaded from outside. No CDN, no tracker, no captcha, no hosted font. A reader's browser talks to your server and to nothing else.
- Your own statistics. Counted here, stored here, with no cookie and no stored IP address.
- Comments and a newsletter, both with built-in spam protection.
- Light on a slow line. Small pages, little JavaScript, laid out for a phone first.
- A backup that pulls. A machine at home fetches the whole installation; the server holds nothing that reaches your copies.
- English and German, chosen in Settings, all the way into the mail.
- Get it running
- Which tag to run
- Configuration
- The site readers get
- Writing
- Pictures and videos
- Language
- Comments
- Newsletter
- Stats
- Accounts
- Import from another system
- Export one entry
- Backup
- Deploy on Fly.io
- Development
docker run -d --name texttile \
-p 4000:4000 \
-v texttile-data:/data \
-e SECRET_KEY_BASE="$(openssl rand -base64 48)" \
-e PHX_HOST=blog.example.com \
-e [email protected],[email protected] \
ghcr.io/texttile-blog/texttile:3The container prepares the data directories, runs the migrations, drops root and
starts the server. All state lives in /data. ADMIN_USERS holds the email
addresses that get an account, so put yours there before the first start.
At the start the server makes an account for every address in it that has none,
and mails each one a link that sets its password. Open the link, choose a
password, and you are in. While no account has a password, the server also
writes the link into its own log (docker logs texttile), so a site whose mail
does not work yet still lets its first admin in. See Accounts.
The image is linux/amd64. An update is a pull and a restart:
docker pull ghcr.io/texttile-blog/texttile:3
docker rm -f texttile
# then the docker run above againEvery build carries the version from mix.exs, and each build gets five tags.
For version 3.0.1 they are:
| Tag | Points at |
|---|---|
3.0.1 |
that one build, and never moves again |
3.0 |
the newest repair of 3.0 |
3 |
the newest build of 3 |
latest |
the newest build there is |
<sha> |
the commit the build came from |
3 and 3.0 are not version ranges. The registry resolves no version logic: a
later build with a fitting number takes the tag over, which is what these two
tags are for. Run 3 to get repairs and new capabilities without a break, 3.0
to get repairs only, and 3.0.1 where nothing may move until you move it.
A build is published only when the version rises, and only after CI passes. A
merge that keeps the version of the build before it publishes nothing and
reports a failure. The publish starts when the CI run of the same commit reports
success, and a red run publishes nothing. An exact tag never moves, so a version
that carried a broken build stays broken: raise the version in mix.exs and
merge the repair.
Each published build also gets a GitHub release. The release tags the commit the
image was built from as v<version>, shows the pull command, and lists the pull
requests since the release before.
Texttile reads its configuration from environment variables at start. A missing variable stops the app at boot with a message that names it.
| Variable | Required in prod | Default | Purpose |
|---|---|---|---|
DATABASE_PATH |
yes | /data/texttile.db (image) |
SQLite database file |
UPLOADS_PATH |
yes | /data/uploads (image) |
directory for uploaded files |
ADMIN_USERS |
first start | none | addresses that get an account, separated by commas |
SECRET_KEY_BASE |
yes | none | signs cookies; generate with mix phx.gen.secret |
PHX_HOST |
yes | example.com |
public hostname |
PORT |
no | 4000 |
HTTP port |
MAIL_ADAPTER |
no | local preview mailbox | resend, postmark, brevo, smtp, or ses |
MAIL_FROM |
no | texttile@PHX_HOST |
sender address for outgoing mail |
CLIENT_IP_HEADER |
no | none, the socket address | header a trusted proxy writes, e.g. fly-client-ip |
Each adapter loads exactly the credentials it needs. Without MAIL_ADAPTER,
mail goes to a preview mailbox (/dev/mailbox, development only).
MAIL_ADAPTER |
Variables |
|---|---|
resend |
RESEND_API_KEY |
postmark |
POSTMARK_API_KEY |
brevo |
BREVO_API_KEY |
smtp |
SMTP_HOST, SMTP_USERNAME, SMTP_PASSWORD, SMTP_PORT (default 587, STARTTLS) |
ses |
AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
Set CLIENT_IP_HEADER only when a proxy stands in front of Texttile and writes
that header itself. The comment rate limit and the reader count both read it, so
a header the caller may write is a header a spammer may change. Without the
variable both count by the address of the connection, which is right everywhere
except behind a proxy. Of a header that carries a list (X-Forwarded-For), the
last entry counts: that is the one the proxy appended, and everything before it
is what the caller sent.
Readers get the list of published entries at /blog, each entry at its own
address, and every published page in the menu. A post lives under the day it went
live, /2026/08/23/harbor-mornings; a page lives at its slug alone,
/about-us. Admins sign in at /login and work at /admin, which sends them on
to the entries at /admin/texts.
The root, /, is the front door. It sends the reader to /blog, or it shows the
one page you picked in Settings > Front page. The list keeps /blog either way,
so a bookmark of it stays good when you change your mind.
The list shows ten entries a page and then a pager. Change the number in Settings
Front page. Over the list stands the archive: one line of years, and the months of the open year under it, only the months that carry something. Both are addresses (
/blog?y=2026&m=8), so a year or a month can be copied and bookmarked. Under an entry stand the way to the entry before it and the one after it, and the About block from Settings > About.
An entry that is not live yet answers at the address it will wear, but only for somebody signed in; a reader gets a 404 there. So the way out of the editor leads into the real reader's page, drafts included.
The blog has an RSS feed at /feed.xml. It carries every published post, newest
first, with the whole entry. Each page points at it from its head, and the footer
carries an RSS link.
The blog can sit behind one shared password (Settings > Access). It is an access word to hand around, not a login: it is stored in plain text, one word opens the whole blog, and it guards the blog or nothing. No entry has a switch of its own.
While the blog is protected, the editor of every entry shows the word under Share, so you never have to go and look it up. Once an entry is live, the same block holds the lines to hand on: the title, the address, and the password under it. Copy puts them on the clipboard.
A blog behind the shared password has no feed: /feed.xml answers 404 and no
page offers a link. A feed reader cannot enter a password, and the entries would
travel out of the gate. Remove the password to get the feed back.
A live entry keeps two texts. The one in the editor is the working copy; the one the readers get is the version you last published. Typing in a live entry moves the working copy alone, so a half-written sentence never reaches the site.
The bar says which of the two is which. As soon as the working copy runs ahead, the state word turns to Live · draft and the button beside it turns to Publish changes. That button hands the text over: the same day, the same address, the same state, and no mail, because the subscriber mail belongs to the entry going out and not to a correction inside it. The menu next to it also offers Discard the changes, which puts the published text back and keeps what you threw away as a version.
Only the title and the body split in two. Tags, the address, the tiles and the switches have one state and go live as you change them, which is what a version has always held.
Signed in, the entry's public address shows the working copy with a strip over it that says so. Signed out, it shows what was published. So does the list, the tag archive, the search field, the feed and the subscriber mail.
The entries overview marks a live entry whose working copy has run ahead with edited since publishing, and the Versions tab marks the version the readers have with live.
Changing the slug or the publish date of a live entry moves it, because the address of a post carries its date. The old address stays alive as a permanent redirect, and the editor lists what is standing under the address field, each with a Delete for the day you want it gone.
Pictures and videos go into an entry the same way: paste one into the body, drop
one on it, or add it to the entry's gallery. Every original file is kept as it
came, below UPLOADS_PATH, and nothing leaves the server: no external player, no
third-party host.
One roof holds for a picture and for a video: Settings > Storage > Biggest upload, 512 MB by default. The browser turns a bigger file away before the upload starts, and the server stops reading one that arrives anyway. Settings > Storage also reports what lies in each folder of the volume, how much it weighs, and how much room is left.
A picture is scaled to display sizes on demand, capped by Settings > Images > Max longer edge. A video is converted once, by ffmpeg, into one MP4 (H.264 and AAC) that every browser plays, plus a poster frame. Settings > Videos > Max longer edge caps it, 1280 px by default; nothing is ever scaled up. A new value applies to what is converted after the change, because a converted video is never made again.
The conversion is the most expensive thing this server does, so it stays out of everybody's way: one video at a time, ffmpeg on one thread, at the lowest scheduling priority, and with idle disk priority where the kernel offers it. While a video converts, the admin area shows the state on its tile and under the entry; the reader's page shows the video once it is ready.
The upload takes .mp4, .mov, .m4v, .webm, .avi and .mkv, and the
server reads which container a file really holds before ffmpeg opens it. A name
is not a promise, and a file that names other files never reaches the converter.
ffmpeg runs with the local disk as its only source.
The container brings ffmpeg. On a development machine, make tools installs it.
A blog has one language, and Settings > Site is where you pick it. English and
German are shipped. The choice reaches everything a person reads: the reader
pages, the admin area, the dates, and every mail the blog sends. It also sets
lang on every page and <language> in the feed.
What you write yourself is not touched. Titles, entries, tags, the About text and the site description stay in the language you wrote them in; only the words around them change.
English is the language of the source, so it has no translation file: a sentence
nobody translated is shown in English. Each other language is exactly one file,
priv/gettext/<code>/LC_MESSAGES/default.po, and it holds all of them, the error
messages and the sentences the browser says included.
To add a language:
# 1. add {"fr", "Français"} to @languages in lib/texttile/i18n.ex
# 2. write the file
mix gettext.merge priv/gettext --locale fr
# 3. translate every msgstr in priv/gettext/fr/LC_MESSAGES/default.po
mix test test/texttile/translations_test.exs # names every gapNothing else changes: the settings menu reads the list, and the new language is there.
Readers can comment under every entry that allows it (the switch is in the entry's settings). There is no approval queue. By default a new address gets one confirmation mail. The comment appears when its reader follows the mailed link, and every later comment from that address appears at once. Turn the confirmation off in Settings > Comments and no comment waits for anything.
A reader can give the address of their own site with the comment. The name over
the comment then leads there, with nofollow, and a bare christel.example is
enough.
Comment while signed in and the form fills itself: the name, the address and the
website come from your account and this blog, the three fields take no typing,
and the comment stands under the entry at once. The sign-in already proved the
address, so no confirmation mail goes out. The comment counts show under each
card, on /blog and on /admin.
Admins see all comments at /admin/comments and on the Comments tab of each
text. There they can do three things to a comment:
- Release a comment whose reader has not confirmed the address. It puts that one comment under the entry. The address itself stays unconfirmed, so the next comment from it waits again.
- Edit the words. The name and the address stay as the reader sent them, and the comment is marked "edited" in the admin area.
- Delete it into the trash. Readers stop seeing it at once, and it waits 30
days at the foot of
/admin/comments, where Restore puts it back. After 30 days the comment is removed from the database for good.
Every admin also gets the comment by mail, unless "Mail me every new comment" is switched off in Settings > Comments. The mail leaves when the comment stands under the entry, so a comment that still waits for its reader mails nobody. The address of the reader is never in it.
Spam protection is built in and always on: a honeypot field, a time trap, and a rate limit per caller. No captcha, no Google, no third parties.
Readers can put their address on the newsletter list through the form in the
footer of every page. The address gets one confirmation mail and receives entries
only after its reader follows the mailed link. Admins see the list at
/admin/newsletter and can add addresses by hand; an added address is confirmed
at once.
When a post goes live with "Email subscribers" checked (the switch is in the entry's settings), every confirmed address gets one plain email: the title, the first paragraph, and the address the entry lives at. If the blog sits behind the shared password, the mail includes it. Every mail carries the way off the list. The email goes out once per entry; publishing the same entry again does not send it again.
The subscribe form wears the same spam protection as the comment form.
Texttile counts its own readers at /admin/stats, and each entry again on its
Stats tab in the editor. Nothing is loaded from anywhere else and nothing is sent
anywhere else.
Every reader page reports itself to this server with one small request. It carries three things: the address of the page, the entry it shows, and the address the reader came from. No cookie is set, none is read, and none is sent with that request.
The server turns the caller into one number for the day: their IP address and their browser line, hashed together with a secret this server draws every morning and keeps in memory only. The secret is never written to disk and is gone at midnight, so no row can be traced back to an address, and nothing links the same reader to two days. That is what "people" counts: a reader who comes back next week counts as one person each time. The IP address itself is never stored.
Four rules keep the numbers about people, not machines:
- The report needs JavaScript, so almost everything that crawls the web never reports at all.
- A browser line that says bot is dropped, and so is a page the browser only fetched ahead.
- One reader counts once per page every half hour, so reloading changes nothing.
- One caller writes at most 60 views a minute.
An admin reading their own blog is not a reader: while you are signed in, no page reports itself. Drafts, scheduled entries and previews count for nobody.
The numbers live in the page_views table in your database, one row per counted
view. They are yours; nothing leaves the server.
The referrer and the address of a page come from the browser, so the tables of sources and of other addresses hold the 20 biggest and say so when they are full. The top entries table counts entries, which the blog knows itself, so it holds 20 without a word.
If a proxy stands in front of Texttile, set CLIENT_IP_HEADER (see
Configuration). Without it every reader behind the proxy shares
one address, and the counter reads them as one person.
You sign in with your email address. There is no username: an account is an address, a password, and the name readers see under the entries.
The accounts are the guest list. Nothing else says who may sign in, and there is no registration, so there is nothing to guess.
An account comes into being in one of two ways, and both end in a mailed link that sets its first password.
At the start of the server. ADMIN_USERS holds the addresses that get an
account, separated by commas:
ADMIN_USERS="[email protected],[email protected]"Every address in it that never had an account here gets one at the start, and a link. The link works one time, and for a week. This is how the first admin of a new installation gets in: nobody is inside yet who could invite them, and there is no window in which a stranger could take the account instead.
Adding an address later works too. Set the variable, restart, and the new address gets its link.
An address that has an account here is never given a second one, so an address whose owner moved to another one does not come back as a stranger's way in.
A deleted account frees its address again, in this variable too: while the address stands here, the next start invites it once more, as a new account. Taking an address out of the variable takes nothing away by itself. Both halves of that are in Taking access away.
From Settings. In Settings > Users, type an address into Invite an admin and the same link goes out. No deploy, no restart. The account appears in the list at once and says it is waiting for its first password. Until the link is used it cannot sign in, Send the link again replaces the link, and Delete takes the account back.
Both ways prove the same thing: whoever reads that inbox is who the account belongs to. Nothing is handed over by hand.
While no account here has a password, the server writes every link it mails into its own log as well:
docker logs texttileThat is the way into a fresh installation whose MAIL_ADAPTER is missing or
wrong. Restarting the server mints a fresh link and writes it again. The moment
one account has a password, the log line stops.
Delete the account in Settings > Users. It cannot sign in from that moment, every session open right now ends, and it is out of the list. Nobody can delete their own account, and the last account cannot go.
What the person wrote stays, and keeps their name. The entries read exactly as
before: the byline under an entry is the name that account carries, and a reader
is told nothing about accounts at all. In the admin area the name says the
account is gone: Julia (deleted) in the author list of an entry, in the
entries list and in the version list. The account itself is in no list of
accounts any more.
The address is free again at once. Invite it from Settings and it gets a new
account, with its own entries from then on; the old one keeps what it wrote. The
same goes for ADMIN_USERS: while the address stands in that variable, the next
start of the server invites it by itself. Taking access away from somebody
named in ADMIN_USERS means deleting the account and taking the address out of
the variable. One without the other lasts until the next restart.
That is the trade this product takes: the variable is operator configuration, and the operator is the person who deploys. If you need more than that, the database on the volume is yours.
There are no roles and no permission matrix. Everybody who can sign in can do everything, which is what a blog of people who trust each other needs.
The sign-in screen has a "Forgot your password?" link. It asks for the email
address of the account and sends a link that sets a new password. The link works
one time, and for 24 hours. Outgoing mail has to work for this, so a real
installation sets MAIL_ADAPTER.
The profile changes the address later, and that change asks for the password first: whoever holds the address holds the account, so a browser somebody left open must not be enough to move it. Readers never see the address; they see the displayed name, or the part in front of the @ while that name is empty.
When the mail cannot go out, another admin deletes the account in Settings and invites the address again. For the only account of a site, fix the mail settings and restart: while nobody can sign in, the link stands in the log.
Texttile imports entries from a zip archive of bundles: one folder per entry, with Markdown, settings, and pictures. A picture can be a file in the bundle or a URL that the server downloads, so a migration zip stays small. IMPORT.md is the complete format contract, written so that a script or an AI agent can convert any export (WordPress, for example) into bundles. The import itself lives in Settings: upload the zip, read the validation report, start the import.
A film travels in the bundle as a file. A picture may come from a URL, a film may not: a film is large, and the import does not carry one over the network.
Every entry hands out a copy of itself as a zip. It stands in the menu at the right of the editor bar, and on every card of the entry list.
beach-days.zip
└── beach-days/
├── index.md
└── gallery/
├── 001_beach.jpg the tiles, in gallery order
├── 002_pier.jpg
├── xxx_001_map.png the files in the words, in reading order
└── xxx_002_walk.mp4
index.md carries the front matter of IMPORT.md and the text below
it, with every reference pointing into gallery/. That makes the zip two things
at once: a Hugo page bundle, and a bundle this Texttile or another one imports
again.
What travels is the entry as the readers have it, and the file as it was uploaded, never a rendition. An entry that was never live has only the working copy, so that is what it exports. The comments stay on the site; a copy of an entry is its text and its pictures.
Texttile is backed up by pulling, not by pushing. A machine you keep, a Raspberry Pi at home or a NAS, fetches the whole installation on a clock of its own. The backup machine holds the token; this server holds nothing that reaches your copies. Whoever breaks into the server therefore cannot delete them, which is the one property a push to an object store does not have.
Switch it on in Settings, section Backup:
- Tick Serve a backup client at /backup. Off on a fresh install.
- Press Create a token. It is shown once, and stored here as a hash. Write it into the configuration of the backup machine now.
- Optionally name the addresses the backup machine calls from. Empty is the usual case: the token alone decides.
The token opens the backup endpoints only, and it only ever reads. Keep it like a password all the same. What it fetches is the database, and the database carries the blog password in clear, the password hash and the live sessions of every account, and the address of every reader who commented or joined the newsletter. Whoever holds the token holds a copy of the site.
The address list is a second lock, never the first. Behind a proxy it is only as
good as CLIENT_IP_HEADER: a proxy that passes a caller's X-Forwarded-For
through instead of appending to it lets the caller write their own address. Set
CLIENT_IP_HEADER only where the proxy writes the header itself.
Last fetched on the same screen dates the last run, with the address it came from. A backup that stopped running says nothing until the day you need it, so that line is where you see it.
| Data | Copied | Why |
|---|---|---|
| the SQLite database | in full, per run | a few MB, and it changes constantly |
| image and video originals | what is new | large, but written once and kept |
| what ffmpeg made of a video | what is new | a conversion no page can wait for |
| the logo and the favicon | what is new | small, and not made again by itself |
| the rendition cache | never | made again the moment a page asks |
Leaving the cache out often halves what travels. After a restore, the renditions are made again as readers arrive.
scripts/texttile-backup.sh in this repository is the client. It needs curl,
jq, flock, and sha256sum (Debian, Raspberry Pi OS: apt install curl jq).
Copy it to the backup machine and write its configuration to
~/.texttile-backup.conf:
TEXTTILE_URL=https://blog.example.com
TEXTTILE_TOKEN=<the token Settings showed once>
BACKUP_DIR=/mnt/backup/texttile
DB_KEEP=30 # dated database copies to keepThen run it from cron, every day at 03:17, with mail on failure:
17 3 * * * /home/pi/texttile-backup.sh >> /var/log/texttile-backup.log 2>&1
It exits non-zero on any failure, so cron reports it.
It sets umask 077 and tightens BACKUP_DIR on every run, so the copies are
readable by their owner alone. Keep them that way, above all on a NAS that
exports the volume: the database in there opens the blog.
What it does, and why:
- It archives, it does not mirror. A file that disappears from the site stays in the backup. A copy that faithfully repeats a deletion is no backup.
- The layout matches the server. Restoring is a copy, not a script.
- The database comes last, after every file, so the copied database never names a picture that is missing from the copy.
- Database copies are dated and rotated (30 by default). A few MB a day buys a month of points to go back to. The originals are never rotated.
- Every file is its own request, downloaded to
.partand moved into place only after its SHA-256 matches. An interrupted run costs one file.
- Copy
files/into the uploads directory (UPLOADS_PATH,/data/uploadsin the image). - Copy the newest
db/texttile-*.dbto the database path (DATABASE_PATH,/data/texttile.dbin the image). - Start the container. The renditions are made again on demand.
Three, all read-only, all wanting Authorization: Bearer <token>. Never a token
in the address line: an address goes into the access log of this server, of every
proxy on the way, and into your shell history.
| Endpoint | Answers |
|---|---|
GET /backup/manifest |
JSON: the database, and every file with size and hash |
GET /backup/db |
a consistent copy of the database, made with VACUUM INTO |
GET /backup/file/:id |
one original, by the id the manifest gave it |
While the feature is switched off, all three answer 404 rather than 403: a scanner learns nothing about what this server can do. An address that is not on the allowlist gets the same answer. Every call is logged, served or refused, and one caller may fetch 600 times a minute.
A caller who knocks too often is turned away before anything else is asked, so a scanner at a switched-off installation costs one line in the log and no work. The first manifest of an existing blog reads every uploaded file once, to hash it. After that a hash is stored beside the file's size and write time, and a manifest is a query: ten gigabytes of pictures cost that one first run and nothing after it.
fly.toml in this repo deploys from source with one volume for the database and
the uploads. Its header documents the one-time setup (app, volume, secrets,
certificate).
A push to main deploys itself, but only after CI passes. The deploy starts when
the CI run of the same commit reports success, and it deploys that commit, not
the head of the branch.
The whole installation is one SQLite file on one volume. SQLite takes one writer, and a volume mounts on one machine. A second machine gets a volume of its own: it would answer readers out of a second, older database and take entries, comments and uploads that the first machine never sees. Both sites work, and each one is missing half the blog.
So never fly scale count 2, never fly machine clone, and never deploy with a
standby. fly status must show exactly one machine. A deploy replaces that
machine in place, which costs a few seconds of downtime.
This is not a Fly rule. Any host runs Texttile the same way: one process, one
/data.
The video conversion decides this, and nothing else does. A blog of words and
pictures runs on one shared CPU and 512 MB of memory (shared-cpu-1x, the
[[vm]] section of fly.toml as it stands).
Videos want more. Give the machine four shared CPUs and 1 GB (shared-cpu-4x)
before you upload the first one:
[[vm]]
cpu_kind = "shared"
cpus = 4
memory = "1gb"ffmpeg runs on one thread at the lowest priority, so the other three keep the site quick while a video converts, and the gigabyte is what the conversion itself needs. Videos also need room on the volume: the original and the converted file live side by side. A machine that stops mid-conversion loses nothing; the queue picks up every unfinished video when the app starts again.
Settings > Version names the version of the running build, and every release raises that number. The page is behind the sign-in and the number stands nowhere else: a public version number tells an attacker which holes to try. Name it when you report a problem.
Requires Elixir 1.19+, Erlang/OTP 28+, Node.js (for browser tests), and ffmpeg
(for the video conversion; make tools installs it).
make tools # install the command line tools: ffmpeg and ffprobe
make start # dev server on port 4000, no configuration needed
make test # JS unit tests, Elixir unit tests, browser tests
make check # everything CI checks: warnings, formatting, tests
make db-pull # pull the production snapshot next to the dev database
make dev-reset # throw the shared development database and uploads awaynpm --prefix assets test runs the JS unit tests on their own: plain
node --test over assets/js_test/, no browser involved.
In development ADMIN_USERS holds [email protected], so an empty database
gets an account at the first start. Nobody can sign in yet, so the link stands
in the server output: open it and choose a password. A second address is invited
from Settings, the same way a real installation does. ADMIN_USERS in .env
replaces the address. To test a
real mail adapter locally, copy .env.example to .env in the main checkout;
dev loads it on every start, from every worktree, and real environment variables
win over .env values.
The development server listens on all interfaces, and make start opens the
address of the network instead of localhost: the address of the cable network
first, then Wi-Fi, then 127.0.0.1. A phone in the same network reaches the
server at that address, and http://localhost:4000 keeps working on this
machine. bin/dev-host prints the address that gets chosen. Everybody in that
network can open the site and the dev routes, so start the server on a network
you trust. DEV_HOST replaces the address: DEV_HOST=localhost make start
writes http://localhost:4000 into the links that leave the server, mails
included.
All git worktrees of the repository share the dev state of the main checkout: the
texttile_dev.db database, the priv/uploads folder, and the .env file.
make db-pull writes the snapshot to texttile-snapshot.db in that same
checkout root. Outside a git checkout, everything stays next to the code.
make dev-reset throws that state away: the database and the uploads together,
because a database without its uploads is a site full of pictures that are not
there. It takes the state of every worktree with it, so stop the development
server first; it refuses while a process holds the database. The next
make start makes an empty installation and applies all migrations.
Text a person reads goes through gettext. After you write a new sentence,
mix gettext.extract --merge puts it into the template and into every language
file; mix precommit refuses a template that is out of date, so a sentence
cannot slip past the translations unnoticed. The sentences the browser hooks say
are the exception the tool cannot see by itself: they are listed in
TexttileWeb.JsStrings, and the layout hands them to assets/js/i18n.js as
data-words.
AGENTS.md holds the permanent rules of this repository: branches, worktrees,
versioning, tests, and the words to use. PHOENIX.md is the framework guide.
IMPORT.md is the bundle format contract.