Everything not listed in this document should behave the same as upstream Ovumcy. If a feature, setting, or behavior is not mentioned here, the upstream documentation is accurate and fully applicable — see the Documentation section of
instructions.mdfor links.
Ovumcy is a self-hosted period and menstrual cycle tracker: a calendar for period start and end days, flow intensity, symptoms and notes, with cycle-phase and prediction views. It is a Go binary serving server-rendered HTML over HTTP, with SQLite for storage, and it supports multiple accounts whose health data is isolated per account.
- Image and Container Runtime
- Volume and Data Layout
- File Models
- Dependencies
- Network Access and Interfaces
- Installation and First-Run Flow
- Actions
- Tasks
- Health Checks
- Backups and Restore
- Limitations and Differences
- Quick Reference for AI Consumers
The image is built here, from the Dockerfile at the repo root: upstream's /app/ovumcy binary, copied byte for byte out of upstream's published image, onto an Alpine base. The upstream image cannot be used directly — it is built FROM scratch, and its layers contain app/, etc/ and usr/ and nothing else. StartOS launches a subcontainer and waits for <rootfs>/proc/1 to appear, which a rootfs with no /proc can never produce, so that image fails every start with Failed to start subcontainer ovumcy. The alpine base supplies the directories a container runtime expects, and with them a shell and coreutils — which is also what makes the chown below possible.
The upstream build it copies from is a main-branch commit, not a release tag. The newest upstream release cannot reset a password without a controlling terminal, and there is no terminal behind a StartOS action, which would leave account recovery — the thing an admin needs when someone is locked out — unreachable. It is pinned by digest, so the bytes are exactly the ones that were tested. UPDATING.md records when to move back to a release tag.
| What | Detail |
|---|---|
| Image source | Built from ./Dockerfile: upstream's binary, pinned by digest, on Alpine |
| Architectures | x86_64, aarch64 (see Limitations on ARM testing) |
| Entrypoint | /app/ovumcy named explicitly; the image declares no ENTRYPOINT |
| Process user | ovumcy (uid 10001), as upstream ships |
The service keeps upstream's unprivileged uid. StartOS mounts volumes root-owned on every start, so the two data paths are given to uid 10001 before anything writes them — by a chown oneshot ahead of the daemon in startos/main.ts, and by runCli before each CLI command, since an action can run before the service has ever started. An idmap on the mount would avoid the chown, but the host refuses it (open_tree_attr IDMAP: Operation not permitted).
Two subcontainers:
ovumcy-sub— the long-running daemon. Attach to it to watch the server:start-cli package attach ovumcy -n ovumcy-sub.ovumcy-cli— created and torn down by each account-management action, which runs the upstream operator CLI (/app/ovumcy users …,reset-password,notify) against the same data volume. It is not running between actions.
Three volumes, which is one more than this service looks like it needs; the third exists so that backups can leave it out.
| Volume | Mounted at | Holds |
|---|---|---|
main |
/app/data |
ovumcy.db, the SQLite database — all accounts and all health data |
fence |
/app/fence |
calendar-feed.fence, the calendar-feed restore fence |
startos |
not mounted | store.json — this package's own state, never visible to the app |
The database is embedded SQLite. Upstream also supports Postgres; this package does not use it.
The fence is a single small file whose whole purpose is to not be in a backup. Ovumcy compares it against the database to tell whether the database in front of it is the one this instance last wrote. Keeping it on its own volume, excluded from backups, is what makes a revoked calendar-feed URL stay revoked across a restore. If the directory is ever unwritable the app fails closed — it disarms every armed feed on each start and logs that it did.
store.json is deliberately on a volume no subcontainer mounts: it holds the application secret and the admin password this package generates, and the application has no reason to be able to read either.
One model, and it maps nothing upstream owns.
| Model | File | Format | Seeded | Rewritten by | Hand edits |
|---|---|---|---|---|---|
store.json |
startos volume, store.json |
JSON | Written in full on install by startos/init/seedStore.ts |
The actions that change settings or credentials | Survive if valid; an invalid value is replaced by its schema default on the next write |
Ovumcy reads no configuration file at all — it is configured entirely by environment variables, which is why there is no model of an upstream config here and nothing on disk to correct by hand. startos/utils.ts turns store.json into that environment on every launch, so every value in it is re-asserted at each start; none of them is a value the user owns inside the container. A setting changed through an action restarts the daemon, which is how it takes effect.
The admin password is stored but is not part of the environment: it lives in Ovumcy's own database, applied through the upstream CLI. That is why rotating it does not restart the service, and why startos/main.ts reads a projection of the store that excludes it.
None.
One interface, serving the whole application — calendar, per-account settings, and the calendar-feed endpoint.
| Interface | Type | Port | Protocol | Purpose |
|---|---|---|---|---|
ui |
ui |
8080 | HTTP | The web application: sign-in, calendar, per-account settings, .ics feeds |
StartOS terminates TLS and forwards plain HTTP, so the container always sees http regardless of how the browser reached it. The session cookie's Secure flag is therefore a package setting rather than something derived from the request — see cookieSecure in the store and Require A Secure Connection To Sign In in settings. The practical consequence is that sign-in works from an https LAN address or from Tor Browser over .onion, and silently fails from a plain http:// LAN address until that setting is turned off.
The OS reverse proxy is trusted (TRUST_PROXY_ENABLED=true, TRUSTED_PROXIES=10.0.3.1,fd00:3::1), and it has to be or no browser can sign in. StartOS terminates TLS and forwards plain HTTP, so the app sees scheme http while the browser's Origin header says https; Fiber's CSRF middleware compares the two and refuses every unsafe request with 403 and reason="invalid referer" — which looks exactly like a rejected password on the sign-in form. Trusting the proxy lets the app read X-Forwarded-Proto, the origins match, and sign-in works.
This is safe because the OS proxy strips any client-supplied X-Forwarded-* header before inserting its own, so exactly one hop is ever present and it is the OS's. Ovumcy's startup NOTE that c.IP() returns a client-supplied leftmost X-Forwarded-For entry therefore does not apply here: that header only ever carries the one address the OS wrote. 10.0.3.1 is the host's fixed address on the LXC bridge — the source address of every proxied request — named exactly rather than as a /24, which would also trust every sibling service on the box.
A consequence worth having: X-Forwarded-For now carries each client's real address, so the per-IP limiters distinguish LAN clients instead of pooling them. Over Tor they still genuinely share one address (the box's own Tor daemon), which is why the login budget is still widened (see Limitations and Differences).
Install generates the application secret into store.json and leaves the service unable to start until an admin account exists.
That block is deliberate. Upstream's own first-run path lets whoever reaches the address first create an account, so this package closes registration by default and creates the admin account itself: the install-time watcher raises a critical task, Set Admin Password generates a password, applies it through the upstream CLI (users create, or reset-password if that account already exists), stores it and returns it. Only then does the daemon start, and by then sign-up is closed.
Ordering constraint: nothing else is runnable until that task is satisfied, and the credential it returns is shown once per run of the action.
Seven actions. The four account actions sit in an Account group; each spins up a throwaway subcontainer and runs the upstream operator CLI against the live database — passwords reach it on stdin, never in argv or the environment. Every action manages something Ovumcy's web UI cannot: the app has no admin role, so one account can never create, reset or delete another, and the server-wide switches are environment variables. All of them work whether the service is running or stopped.
| Action | When to run it | What it changes | Cost / repeat safety |
|---|---|---|---|
set-admin-password |
First install, or to rotate the admin credential | The admin row in the database, and adminPassword in the store |
Seconds. Safe to repeat — each run replaces the password and signs that account out. On a rotation the returned password is good for one sign-in: upstream then requires a new one (see below) |
create-account |
Adding another person to the server | Inserts an account in the database | Seconds. Not idempotent: a second run for the same address fails, by upstream design |
reset-account-password |
Someone has lost both their password and their recovery code | That account's password hash and session version; the store too if the address is the admin's | Seconds. Safe to repeat. Signs that account out everywhere. Its account list is read from the database each time the form opens |
delete-account |
Removing someone who has not deleted their own account in the app | Deletes the account and all of its data | Seconds. Destructive and irreversible. Same account list as above |
settings |
Opening registration, allowing plain-HTTP sign-in, the reminder schedule, the webhook guard, audit logging | The corresponding keys in the store | Seconds, restarts the daemon. Idempotent. Turning off the secure-connection requirement weakens session security |
run-reminder-pass |
Checking that a newly configured webhook actually delivers | Nothing stored; sends outbound webhook requests | Seconds. Safe to repeat, but each run really does notify everyone currently due |
rotate-app-secret |
The application secret is believed to have leaked | secretKey in the store |
Seconds, restarts the daemon. Safe to repeat and costly: every session, TOTP enrolment, stored webhook URL and feed URL is invalidated |
No action is hidden; all seven are user-facing.
A CLI-set password is single-use; a CLI-created account's is not. Upstream distinguishes the two, and so do the actions' result messages. After reset-password — which is what set-admin-password runs once the admin account exists, and all of reset-account-password — the next sign-in is redirected straight to a forced password change, so the value the action returned stops working after it. After users create the account signs in and goes to onboarding with the password it was given. The practical consequence: the store's adminPassword goes stale the moment a rotated admin signs in and chooses their own, and that is upstream behavior, not drift.
reset-password needs the fence marker, which only a started server writes. On a never-started install the command refuses, naming the fence. This never bites in practice, because the only path that runs before the first start is the users create branch of set-admin-password — but it is why that action asks Ovumcy which branch it is on rather than assuming.
Three upstream CLI capabilities are deliberately not exposed as actions: users set-email (repair for a legacy-format address), repair symptom-names (a break-glass data repair for a migration that refuses to run), and webhook set (each person sets their own webhook inside the app). Each is a one-file addition if a real need appears.
One task, and it is the reason a fresh install does not start on its own.
- Set the admin password — raised by
startos/init/watchCredentials.tswheneverstore.jsonhas noadminPassword. Severity critical, so the service will not start and the ordinary controls are replaced by the prompt. Cleared by runningset-admin-password, which writes that key. It can return: if the store is restored from a backup taken before the password was set, or the key is otherwise absent, the task comes back.
One check, on the daemon itself.
- Web Interface —
checkPortListeningon 8080, with a 30-second grace period. Within the grace window a failure reads as "starting", which covers the first start of a fresh install: the binary applies database migrations before it listens, and on a new install it creates the database first.
A failure after that window means the process is up but not listening, or has exited — the service log is the next place to look. The most likely causes are a store.json with no secretKey (the daemon refuses to start and says so) and an unwritable /app/data (the chown oneshot is what normally prevents this — if it failed, the service log says so before the daemon's first line).
The strategy is whole-volume snapshots of main and startos, taken with the service stopped, so the SQLite file is quiescent while it is copied and no dump or checkpoint step is involved. Both volumes have to be in it: a database restored without the secret that sealed its sessions, TOTP secrets, webhook URLs and feed URLs would have none of those readable.
fence is excluded on purpose, and this is the one place where a restore is intentionally not a faithful rewind. After a restore the fence no longer matches the database in front of it, so Ovumcy disarms every armed calendar feed on its next start and logs that it did. That is what stops a feed URL someone revoked from coming back live when an older database is restored. Each account re-generates the subscribe URLs it still wants from the app's own settings; nothing else needs doing.
- aarch64 is built but has not been run on ARM hardware. Both halves of the image are genuinely multi-arch — the pinned upstream digest is an index carrying
linux/amd64andlinux/arm64, and so is the alpine base — so the ARM.s9pkcontains upstream's own arm64 binary rather than an emulated x86 one. What is unverified is the running service: every on-box test has been on x86_64. - The image is rebuilt here rather than used as published. Upstream's
FROM scratchimage cannot start as a StartOS subcontainer at all, so this package copies the upstream binary — by digest, unmodified — onto an alpine base. The Cosign signature and SLSA provenance apply to the image the binary came from, not to the image this package ships;UPDATING.mdkeeps the digest check in the bump procedure. See Image and Container Runtime. - The upstream binary comes from a main-branch commit rather than a release, so this package is ahead of upstream's newest tag. Without it, account recovery does not work at all on StartOS.
- No email, so no self-service password reset. The package configures no SMTP. Account recovery is the recovery code Ovumcy issues at account creation, or the
reset-account-passwordaction. - OIDC / SSO is disabled (
OIDC_ENABLED=false) and not exposed. It would need HTTPS to the container plus a redirect URL, and auto-provisioning would add a second route to account creation beside this package's actions. - Postgres is not supported. SQLite only, which is upstream's own recommended baseline for a single private deployment.
- The per-IP login budget is widened from upstream's 8 attempts per 15 minutes to 40. Over Tor every client arrives through the box's own Tor daemon and genuinely shares one address, so the stock budget is spent by the whole household together and would lock everyone out at once. Nothing else is relaxed — the per-account logout budget and the deliberately tight calendar-feed budget keep upstream's defaults.
- HSTS is off (
HSTS_ENABLED=false), where upstream defaults it to whateverCOOKIE_SECUREis. StartOS owns the edge, and pinning a.localor.onionhost to HTTPS in visitors' browsers for a year is not this package's call. - Sign-in over plain HTTP fails until a setting is changed.
COOKIE_SECUREis on by default; turning off Require A Secure Connection To Sign In insettingsis the escape hatch. - Registration is closed by default, where upstream's default is open. Open Registration in
settingsreverses it. - The daily reminder pass is on by default, where upstream ships it off. StartOS gives a user no cron, so leaving it off would make webhook reminders unreachable. It stays inert until someone configures a webhook URL in the app.
- Three upstream CLI commands are not exposed —
users set-email,repair symptom-names,webhook set(see Actions).
package_id: ovumcy
image: built from ./Dockerfile (upstream ghcr.io/ovumcy/ovumcy-web binary on Alpine)
process_user: ovumcy (uid 10001)
architectures: [x86_64, aarch64]
subcontainers: [ovumcy-sub, ovumcy-cli]
volumes:
main: /app/data
fence: /app/fence
startos: not mounted
file_models:
- store.json
startos_managed_env_vars:
- SECRET_KEY
- DB_DRIVER
- DB_PATH
- CALENDAR_FEED_FENCE_PATH
- PORT
- REGISTRATION_MODE
- TZ
- COOKIE_SECURE
- HSTS_ENABLED
- TRUST_PROXY_ENABLED
- PROXY_HEADER
- TRUSTED_PROXIES
- RATE_LIMIT_LOGIN_MAX
- RATE_LIMIT_LOGIN_WINDOW
- REMINDER_SCHEDULER_ENABLED
- REMINDER_SCHEDULER_HOUR
- AUDIT_LOG_ENABLED
- WEBHOOK_BLOCK_PRIVATE_ADDRESSES
- OIDC_ENABLED
dependencies: none
interfaces:
ui: { type: ui, port: 8080 }
actions:
- set-admin-password
- create-account
- reset-account-password
- delete-account
- settings
- run-reminder-pass
- rotate-app-secret
tasks:
- { action: set-admin-password, severity: critical }
health_checks:
- ovumcy