Deploy to Oracle Cloud
The supported target is an Oracle Always Free VM.Standard.E2.1.Micro: x86_64,
1 GB RAM, $0/month. A 2 GB swap file is a hard requirement. The systemd unit also
applies MemoryHigh=600M and MemoryMax=700M, so a runaway backend cannot take
the VM down. An Arm A1.Flex shape, when a region has capacity, lifts these
constraints but is not assumed.
Deployment is a set of invoke tasks run over ssh from your own machine.
Join the tailnet first
Tailscale is the only front door for the app. Join the VM to your tailnet before running setup:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
Setup then configures tailscale serve --bg 8080, publishing the app at the
node's HTTPS root while uvicorn stays bound to 127.0.0.1:8080. There is no
login page and no public-internet exposure. The node keeps whatever tailnet name
it already has.
Secrets
mkdir -p .deploy
cp .deploy.example/.env .deploy/.env
chmod 600 .deploy/.env
.deploy/ is gitignored. Set ECHOWORDS_DEPLOY_HOST to the VM's ssh
destination — its public address, as ubuntu@203.0.113.10: administration rides
public ssh, only the app is tailnet-only. An environment variable of the same
name overrides it for a one-off target. The deploy resolves the host before
building anything, so a missing or unedited value fails immediately rather than
after a frontend build.
Fill in the provider keys and AnkiWeb credentials as described in Configuration. They never enter the repository, a test fixture, or a log line.
Set up and deploy
inv setup-app --with-host-prep # one-time, idempotent
inv deploy --ref=main
inv status
inv logs
setup-app installs Node 22, uv, and Tailscale, clones the repository, installs
and enables the service, provisions and activates a 2 GB
/swapfile, and verifies its format, active capacity, and single canonical
/etc/fstab entry. It creates a missing swap file and can grow an undersized
file that already has a swap signature, but fails without overwriting an existing
non-swap file, symlink, special path, or ambiguous fstab configuration. Swap
creation, activation, and verification failures stop setup.
The same pass hardens sshd and enables an explicit fail2ban sshd jail (3 failures in
10 minutes, escalating 1-day bans capped at 30 days). The jail uses the systemd
backend and excludes Tailscale's 100.64.0.0/10 range, so tailnet administration
cannot ban itself; public ssh — the deploy path, and whatever else reaches port 22
from the internet — is subject to it. Setup also disables rpcbind and bounds the
system journal, by size (200 MB) and by age (three months). The image ships no
logrotate, so setup installs it and enables its timer — on a box without cron,
nothing else would run it — and the files the distribution's own configs name, the
record of failed logins among them, stop growing for the life of the host. Rotation
is monthly, so a file that has never been rotated is first trimmed a calendar month
after the pass. Setup also tightens apt's periodic autoclean and empties the package
cache, and it installs a weekly timer that sweeps what no owner clears: a stray
collection backup left in the home directory after a month, and the partial writes a
kill leaves behind in the data directory after a day. It leaves the host firewall as it finds it: the loopback and terminal-REJECT
rules are re-asserted only when absent, and a rejected change is skipped instead of
failing the pass. It deliberately leaves an existing checkout and running service
untouched, and on a fresh host it does not start the service.
deploy is the only code-and-PWA activation path. It pins the ref to a single
commit, so your local branch and any uncommitted work take no part in what ships —
the ref only has to exist locally and be pushed to the origin the VM clones from.
It requires a clean local checkout of the requested ref, checks out that same
commit on the VM, runs uv sync --no-dev, builds _static/ on the VM with
uv run --no-dev inv build-static, syncs the secrets, starts or restarts the
unit, and fails unless /api/health answers within 30 seconds.
The remote checkout is inspected for modified tracked files and unexpected
untracked files both before checkout and before sync; deployment stops and prints
them instead of deleting anything. Gitignored runtime paths such as .deploy/,
data/, .venv/, and _static/ stay in place.
Warning
Never edit files on the server. The remote checkout is a deploy target, not a
working copy — fix it in the repository and deploy again. Host changes belong
in the setup-app / host-prep tasks so the VM stays reproducible from the
repository.
Checking the result
A deploy is finished only when its health poll passes. Confirm afterwards with
inv status and inv logs.
inv status reports the main process's current VmRSS and lifetime VmHWM
together, plus the service cgroup's MemoryCurrent, MemoryHigh, and MemoryMax.
It also prints the root filesystem, the size of the data directory and what the
journal occupies, so disk growth is read from the same command. It reports cgroup
memory.peak separately only on kernels that export that file; Ubuntu 22.04's 5.15
kernel may report it as unsupported. The command fails if the service or unit is
absent rather than accidentally reading the root cgroup. The service's Tailscale
readiness loop makes reboot startup deterministic.
To build the PWA locally without deploying, use inv build-static.
Rebuilding the Anki note type
inv rebuild-note-type deletes the EchoWords note type together with every
note of it, so the next card added recreates it with the current fields and
templates. It is the only destructive command here: it stops the service, prints
what it would delete and how many notes, and removes nothing until you type
yes. The console command behind it, echo-words rebuild-note-type, deletes
nothing without --yes, and fails instead of reporting success when it finds no
collection where the service keeps one. The service is started again whatever
you answer, and whatever goes wrong in between.
Run it only when a release changes the note type — inv deploy then fails every
add with note type EchoWords is misconfigured, because the app refuses to
rewrite a note type it did not create.
Deleting a note type is an Anki schema change, so AnkiWeb demands a one-way
full sync afterwards. The confirmed rebuild performs it, in three steps: it syncs
normally with AnkiWeb, then deletes, then uploads the result. Every Anki app you
own then offers to download the collection once. Confirm that download — until
you do, that device keeps the old note type and sees none of the new cards. The
sync needs the AnkiWeb credentials, which the CLI reads from the same
.deploy/.env systemd hands the service, so the rebuild refuses to delete
anything when they are missing.
The order is what makes it safe. The upload replaces the AnkiWeb copy of every deck with the server's, so the server first takes everything AnkiWeb holds; the upload then differs from AnkiWeb by the deletion alone. When the collection is already stranded from an earlier attempt and cannot sync normally, the server takes AnkiWeb's copy outright first: all it can hold that AnkiWeb does not is EchoWords notes and their note type, which the rebuild deletes anyway.
Warning
Sync your other devices first. A device holding reviews it has never sent to AnkiWeb is asked for a full download afterwards and loses them — no sync direction can prevent that. If the upload fails after the deletion, the local deletion stands and the command says so: run it again to finish the upload, it does not delete twice. A rebuild that cannot reach AnkiWeb at all changes nothing.
Clearing stored sense labels
A note keeps the field content it was made with. When a release changes what a sense label may say, notes already in the deck keep the label they were made with, and it goes on showing beside the headword on the card the translations answer — which is where a label written under the old rule can be the answer itself.
inv clear-sense-labels empties those, and only those. It stops the service,
names every note whose stored label the current rule would not print, and writes
nothing until you type yes. Nothing else about a note changes: the word, the
translations, the sentences, the audio and the review history all stay, so this
is not a rebuild and costs no card its scheduling. The console command behind it
is echo-words clear-sense-labels, which writes nothing without --yes.
A label is only judged where the note's deck belongs to a configured language — that deck is the only record of the source language a note carries. Notes in any other deck are left alone and counted in the report, so a partial sweep says so.
The command then syncs with AnkiWeb itself, and says whether that succeeded. Nothing else would deliver it: the service syncs off its own adds, so an edit made while it is stopped would sit in the server's collection until you happened to add a word, and your Anki would go on showing the old labels. Emptying a field is no schema change, so the ordinary merging sync carries it; where AnkiWeb asks for a one-way full sync anyway, the command refuses to choose that direction and says so — the labels are emptied on the server, and resolving the sync in Anki delivers them. A run whose sync failed is simply repeated: it has nothing left to empty and syncs again.
Filling in a missing recording
A note keeps what it was made with, audio included. While the head of the audio chain was answering nothing, a word the engines could not speak either was carded silent, and it stays silent on its own cards however well the chain works now.
inv backfill-recordings fills those, and only those. It stops the service, names
every note whose audio field is empty, and writes nothing until you type yes.
Confirmed, it fetches each headword through the chain as it stands today — a human
recording from Commons first, then the local voice, then edge-tts — and attaches
what comes back. Nothing else about a note changes, and a word the chain still
cannot speak is left as it is and counted in the report. The console command
behind it is echo-words backfill-recordings, which writes nothing without
--yes.
The fetches are spaced: Commons answers a burst with a throttle, and a throttled
word is left to the local voice as though Commons had never had it. --replace-
synthetic widens the sweep to those words — a note whose recording one of the
engines made is asked for again, and the human recording replaces it where
Commons now answers. Without the flag a note that already carries audio is never
touched.
As with the label sweep, a note is only filled where its deck belongs to a configured language, and the command syncs with AnkiWeb itself and says whether that succeeded. The sync leaves its media transfer running in the background, so the command waits for that to finish rather than exiting from under it — a run that reports a delivery has made it. Nothing but the audio field of a silent note changes, so this costs no card its scheduling.
Releases
inv ver-bug, inv ver-feature, and inv ver-release bump the version in
src/echo_words/__about__.py, commit it, and push the commit together with its
vX.Y.Z tag. CI runs on that push and, when it succeeds, publishes the package
to PyPI and creates the GitHub release.