Upstream docs: https://vikunja.io/docs/ Upstream repo: https://github.com/go-vikunja/vikunja
Everything not listed in this document should behave the same as upstream Vikunja. If a feature, setting, or behavior is not mentioned here, the upstream documentation is accurate and fully applicable.
Vikunja is an open-source, self-hostable to-do and project manager — kanban boards, gantt charts, table views, attachments, labels, filters, and CalDAV in one app.
This package wraps Vikunja for StartOS. The first user is created via a gated StartOS Action (public registration is disabled by default), SMTP can be sourced from either StartOS's system SMTP or a custom server, and CORS is managed automatically from every address the service is reachable at.
- Image and Container Runtime
- Volume and Data Layout
- Installation and First-Run Flow
- Configuration Management
- Network Access and Interfaces
- Actions
- Backups and Restore
- Health Checks
- Dependencies
- Limitations and Differences from Upstream
- What Is Unchanged from Upstream
- Contributing
- Quick Reference for AI Consumers
| Property | Value |
|---|---|
| Image source | Upstream vikunja/vikunja, unmodified |
| Helper image | busybox (init-time only) |
| Architectures | x86_64, aarch64 |
| Base | FROM scratch, USER 1000 |
| Entrypoint | Default upstream entrypoint (web server) |
| HTTP port | 3456 |
The busybox helper image is invoked once during init to create /data/db and /data/files and chown /data to UID 1000. It is not part of the runtime daemon chain.
| Volume | Mount Point | Purpose |
|---|---|---|
main |
/data |
Vikunja's own persistent data: SQLite database and attachments |
startos |
(not mounted in) | StartOS package state — store.json (read host-side via FileHelper) |
Vikunja's data and StartOS's package state are kept on separate volumes so the
two never share a tree: main mirrors what upstream Vikunja persists (the
database and the files directory), while startos holds only this package's
bookkeeping. Layout inside the main volume:
/data/
├── db/
│ └── vikunja.db # SQLite database (VIKUNJA_DATABASE_PATH)
└── files/ # Task attachments (VIKUNJA_FILES_BASEPATH)
store.json (JWT secret, primary URL, toggles, SMTP) lives on the startos
volume and is read host-side through the SDK's FileHelper — it is never mounted
into the container.
The main volume root is mounted at /data so Vikunja can chown the entire subtree. Mounting subpath: 'db' directly does not work for scratch images — those host directories would be owned by UID 0 and could not be chown'd from inside the user-namespace subcontainer.
After install, one critical task appears on the Vikunja service page:
- Create User. Public registration is disabled by default, so this action is the only way to create the initial account. It accepts a username and email, generates a strong password (never prompts for one), runs
vikunja user createinside a temporary subcontainer, and returns the credentials. The same action stays available afterward for adding more users; the critical task resolves once any user exists — including users created outside this action, since the check asks Vikunja rather than trusting theinitialUserCreatedflag alone.
The primary URL is auto-seeded to a .local address on install, so the service is reachable immediately — there is no setup task for it. It governs links in outgoing email, not access: the web UI works at every address the service is exposed at. Change it any time with the Set Primary URL action (e.g. to a Tor .onion or a custom domain); StartOS re-prompts with an important task if the chosen URL later becomes unreachable, which affects email links only and never stops the service.
A persistent JWT secret is stored in store.json, so container restarts and updates do not log everyone out.
ensureSecret runs on every init kind and generates one only when the store has none — the read-before-write is what preserves the secret across updates and restores. It deliberately does not also gate on kind === 'install'. That gate reads like a second layer of the same protection, but it was the only thing standing between an absent secret and one being minted: main throws when VIKUNJA_SERVICE_SECRET is empty, so any path reaching a later init without one — an upgrade whose stored value didn't survive, a restore from a store predating the field — left the service permanently unable to start, retrying every 10s with nothing able to fill the value in. Generating on demand costs one forced re-login in the case where the secret was already lost; gating on install costs the service.
Once a user exists, log in at any address the webui interface is exposed at — not only the primary URL.
| StartOS-Managed | Upstream-Managed |
|---|---|
Primary URL → VIKUNJA_SERVICE_PUBLICURL |
All in-app preferences (theme, language, views) |
Persistent JWT secret → VIKUNJA_SERVICE_SECRET |
Per-user account, profile, notifications |
| Public registration on/off | Project / list / task management |
| Self-service user deletion on/off | Sharing, teams, kanban, filters |
| Public link sharing on/off | Webhooks, API tokens |
| Email reminders on/off | TOTP enrollment |
| Maximum attachment size | Migration imports (Todoist, Trello, Asana, etc.) |
| SMTP (disabled / system / custom) | |
| CORS origins (every reachable address) | |
| Time zone (fixed to UTC) | |
| CalDAV and TOTP toggles (both forced on) |
All Vikunja configuration is plumbed via environment variables (VIKUNJA_<SECTION>_<KEY>) — there is no on-disk config.yml. getVikunjaEnv(store, smtp, cors) in startos/utils.ts builds the env: the stored settings pass straight through, and the caller supplies the VIKUNJA_MAILER_* fragment. Only the daemon (main.ts) and the Send Test Email action send mail, so they resolve SMTP — reading StartOS system SMTP only when the user chose it as the source — and pass the result via mailerEnv(). The other CLI commands don't send email, so they pass {} and never touch SMTP.
The web UI is a browser app calling the API on the same origin it was loaded from, so every address the service is exposed at must be an accepted origin — StartOS lets the user decide where a service is reachable, and several addresses are valid at once. main.ts reads them with getWebuiUrls() and passes the list as VIKUNJA_CORS_ORIGINS. The read is a .const, so enabling Tor or a tunnel later re-runs main with the new address already allowed.
Two Vikunja behaviors shape this (both in its pkg/config/config.go):
- Origins are whitespace-separated, not comma-separated. Vikunja stores an env value as a plain string and reads it back through viper's
GetStringSlice, which splits withstrings.Fields. Vikunja appendspublicurlto the list itself, so it needs no entry of its own. - CORS on with an empty
publicurlis fatal:log.Fatalf("service.publicurl is required when cors.enable is true"), and CORS defaults on. Two consequences.main.tsfalls back to any reachable address when the store has no primary URL, and drops CORS entirely if there is no address at all, so the daemon can never abort here. And CLI callers passcors: null— which setsVIKUNJA_CORS_ENABLE=false— because CORS is meaningless for a command that serves no HTTP, and leaving it at the default would make every action inherit the same requirement. That matters at init, where the account check runs before the primary URL is seeded.
Mutable settings persist in store.json on the startos volume. Each setting is keyed by the Vikunja env var it populates, with the value stored in the exact form the env expects ('true'/'false', size strings), so getVikunjaEnv() passes them straight through. The exceptions are initialUserCreated (internal package state, never an env var) and the SMTP config, which can't be a flat env value — system credentials are read from StartOS at runtime — so it stays structured and resolves to VIKUNJA_MAILER_*.
initialUserCreated is a cache, not the source of truth. Only the Create User action sets it, so on its own it records "did the user bootstrap through us", not "does an account exist" — an account created any other way (registration briefly enabled, a restore from a store that predates the flag) left it false and the critical task reappeared forever. Init now trusts the flag when it is set and otherwise asks Vikunja directly via listVikunjaUsers(), healing the flag when accounts turn out to exist. The CLI probe therefore runs only on a fresh install or a stale store, never on a steady-state service.
A filed task does not retract itself. StartOS keeps a task active until something clears it, so declining to re-create it on the next pass changes nothing — and a critical one blocks the service from starting at all. Whenever a user is known to exist, init therefore clears it explicitly with sdk.action.clearTask(effects, '<id>:user-create'); createTask derives that replay key as `${packageId}:${actionId}` when the caller supplies none. The call is a no-op when no task is filed, so it is safe on every init, and it runs on the cached-flag path too — a service that healed its flag under an earlier build still carries the stale task, and this is what releases it. Note the asymmetry in StartOS: the user-facing clear-task RPC refuses to clear a critical task without --force, while the service's own clearTasks effect has no such guard. A user should not be able to dismiss a critical task; the service that raised it may retract it.
Because these are init handlers, the self-heal runs when init runs — install, update, restore, or container rebuild — not on a plain start. That matters: a critical task blocks start, so a service that is already stuck cannot clear itself by being started. It clears on the next update, which is the path a stuck install actually arrives by.
| Key | Default | Mutated by |
|---|---|---|
initialUserCreated |
false |
Create User (self-heals from vikunja user list) |
VIKUNJA_SERVICE_SECRET |
generated on install | (internal — never overwritten) |
VIKUNJA_SERVICE_PUBLICURL |
auto-seeded .local URL |
Set Primary URL |
VIKUNJA_SERVICE_ENABLEREGISTRATION |
'false' |
Enable / Disable Registration |
VIKUNJA_SERVICE_ENABLEUSERDELETION |
'true' |
Enable / Disable Self-Service User Deletion |
VIKUNJA_SERVICE_ENABLELINKSHARING |
'false' |
Enable / Disable Link Sharing |
VIKUNJA_SERVICE_ENABLEEMAILREMINDERS |
'false' |
Enable / Disable Email Reminders |
VIKUNJA_FILES_MAXSIZE |
'20MB' |
Set Max Attachment Size |
smtp |
{ selection: 'disabled' } |
Configure SMTP |
smtpAdvanced |
{ skipTlsVerify: false, authType: 'plain' } |
Configure SMTP (Advanced group) |
A change to any of these triggers a daemon restart so the new env takes effect.
| Interface ID | Type | Port | Protocol | Path | Purpose |
|---|---|---|---|---|---|
webui |
UI | 3456 | HTTP | / |
Vikunja web app |
Single MultiHost ('main') with one bound port. StartOS publishes the interface over LAN (.local), Tor (.onion), and any custom domains the operator adds; TLS is terminated at the StartOS edge.
CalDAV is reachable through the same web interface at /dav/... (VIKUNJA_SERVICE_ENABLECALDAV=true). It is not exposed as a separate StartOS interface card — point your CalDAV client at any address the webui interface serves. CORS is skipped for /dav and /feeds upstream (CalDAV needs its own OPTIONS handling), so the allowlist does not affect it.
Three groups appear in the StartOS UI (sorted alphabetically): Accounts, Email, Other. Names below match the literal i18n('...') strings in the action source.
| Display name | Action ID | Availability | Notes |
|---|---|---|---|
| Create User | user-create |
any | Username + email only — generates and returns a strong password (never prompts). Surfaced as the critical install task until a user exists (initialUserCreated flag, verified against vikunja user list when unset); stays available for additional users. |
| List Users | user-list |
any | Parses Vikunja's user list table into per-user accordions (ID, username, email). |
| Reset User Password | user-reset-password |
only running | vikunja user reset-password --direct. Generates and returns a strong password (never prompts). For lockout recovery. |
| Delete User | user-delete |
only running | vikunja user delete --now. Immediate and irreversible (action warning is the confirmation). |
| Enable Registration / Disable Registration | toggle-registration |
any | Dynamic label. Default disabled. |
| Enable Self-Service User Deletion / Disable Self-Service User Deletion | toggle-user-deletion |
any | Dynamic label. Default enabled. |
| Display name | Action ID | Availability | Notes |
|---|---|---|---|
| Configure SMTP | manage-smtp |
any | Disabled / system / custom selector — visually mirrors /system/email. Advanced fields nested. |
| Send Test Email | testmail |
any | vikunja testmail. Takes a recipient address and confirms delivery via the configured SMTP. |
| Enable Email Reminders / Disable Email Reminders | toggle-email-reminders |
any | Dynamic label. Default disabled. Warns if SMTP is not configured when enabling. |
| Display name | Action ID | Availability | Notes |
|---|---|---|---|
| Set Primary URL | set-primary-url |
any | Change the primary URL used for links in outgoing email (.local is auto-seeded on install — no setup task). Does not gate access; the UI works at every reachable address. Reactive: the daemon restarts when it changes. |
| Enable Link Sharing / Disable Link Sharing | toggle-link-sharing |
any | Dynamic label. Default disabled. Warns about exposure when enabling. |
| Set Max Attachment Size | max-attachment-size |
any | Change VIKUNJA_FILES_MAXSIZE (string format like 20MB, 200MB, 2GB). |
| Run Diagnostics | doctor |
any | vikunja doctor output for troubleshooting install or startup issues. |
Every action that shells into Vikunja runs in a temporary subcontainer with /etc/passwd and /etc/group planted (the upstream FROM scratch image has neither) and the full env block plumbed in.
sdk.Backups.ofVolumes('main', 'startos') snapshots both volumes — the main volume (SQLite database and every uploaded attachment) and the startos volume (store.json: JWT secret, primary URL, toggles, SMTP). SQLite's -wal, -shm, and -journal sidecar files are excluded, since capturing them mid-write could restore an inconsistent database.
Restore re-runs the standard init chain: seedFiles → initVolumeLayout → ensureSecret (no-op when a secret is already in the restored store) → watchInitialUser → setupPrimaryUrl. No restore-specific migrations.
| Check | Type | Verifies | Grace period |
|---|---|---|---|
| Web Interface | daemon ready |
checkPortListening on port 3456 |
30 s |
Vikunja runs SQLite migrations on startup; the grace period accounts for migration time on slow disks. The success and failure messages shown in the StartOS UI are "The web interface is ready" and "The web interface is not ready".
None. Vikunja runs against an embedded SQLite database — no Postgres, MySQL, or Redis sidecar is required.
- No riscv64. The upstream Docker image is published for
amd64andarm64only. - SQLite only. PostgreSQL and MySQL/MariaDB backends are not exposed. Upstream recommends Postgres for instances with more than a handful of users and treats SQLite as the lighter option; we ship SQLite deliberately, because it fits the single-user / small-team home-server use case StartOS targets — one fewer daemon, and a backup/restore that is just a file copy.
- Public registration is disabled by default. Upstream defaults to
enableregistration: true; we override tofalse. Re-enable via the Enable Registration action if needed. - Public link sharing is disabled by default. Upstream defaults to
enablelinksharing: true; we override tofalsebecause attachments on a shared project would otherwise be readable by anyone with the link. - Email reminders are disabled by default. Upstream defaults to
true; without SMTP they would silently no-op, so we default off and warn if reminders are enabled before SMTP is configured. - CalDAV is enabled but not surfaced as its own interface card. Reachable at
https://<primary-url>/dav/...— point your CalDAV client at that path. - Time zone is fixed to UTC (
VIKUNJA_SERVICE_TIMEZONE=UTC). Per-user time zones in the Vikunja UI work as upstream. - No on-disk
config.yml. Everything is wired throughVIKUNJA_*environment variables. Anything documented as configurable only viaconfig.ymland not exposed through env vars is not reachable on this package.
The following work as documented upstream:
- Task management (kanban, gantt, table, list views; labels, filters, priorities, due dates, reminders, attachments)
- Project sharing, teams, and per-project permissions
- Migration imports from Todoist, Trello, Asana, Microsoft To Do, etc.
- API and personal API tokens (
/api/v1/...) - Webhooks
- TOTP / 2FA enrollment per user
- CalDAV access at
/dav/ - Background jobs and recurring task scheduling
- All in-app user preferences (language, theme, default views, notification settings)
- Database migrations on startup
- The
vikunjaCLI's full behavior when invoked through actions (user create,user list,user delete,user reset-password,testmail,doctor)
Build and development workflow follow the StartOS packaging guide: https://docs.start9.com/packaging. Keep README.md, instructions.md, and AGENTS.md in sync with any change to user-visible behavior or package structure.
package_id: vikunja
architectures: [x86_64, aarch64]
volumes:
main: /data # SQLite database + attachments
startos: (not mounted) # store.json package state
ports:
webui: 3456
dependencies: none
startos_managed_env_vars:
- VIKUNJA_SERVICE_INTERFACE
- VIKUNJA_SERVICE_ROOTPATH
- VIKUNJA_SERVICE_PUBLICURL
- VIKUNJA_SERVICE_SECRET
- VIKUNJA_SERVICE_TIMEZONE
- VIKUNJA_SERVICE_ENABLECALDAV
- VIKUNJA_SERVICE_ENABLETOTP
- VIKUNJA_SERVICE_ENABLEREGISTRATION
- VIKUNJA_SERVICE_ENABLELINKSHARING
- VIKUNJA_SERVICE_ENABLEUSERDELETION
- VIKUNJA_SERVICE_ENABLEEMAILREMINDERS
- VIKUNJA_DATABASE_TYPE
- VIKUNJA_DATABASE_PATH
- VIKUNJA_FILES_BASEPATH
- VIKUNJA_FILES_MAXSIZE
- VIKUNJA_MAILER_ENABLED
- VIKUNJA_MAILER_HOST
- VIKUNJA_MAILER_PORT
- VIKUNJA_MAILER_FROMEMAIL
- VIKUNJA_MAILER_USERNAME
- VIKUNJA_MAILER_PASSWORD
- VIKUNJA_MAILER_FORCESSL
- VIKUNJA_MAILER_SKIPTLSVERIFY
- VIKUNJA_MAILER_AUTHTYPE
actions:
- user-create
- user-list
- user-delete
- user-reset-password
- toggle-registration
- toggle-user-deletion
- manage-smtp
- toggle-email-reminders
- testmail
- set-primary-url
- toggle-link-sharing
- max-attachment-size
- doctorMaintainer pointers:
- Env vars are built in
getVikunjaEnv(store, smtp, cors); the mailer fragment comes frommailerEnv(), which only the daemon and Send Test Email build — all instartos/utils.ts. Only the daemon passescors; CLI callers passnulland get CORS off. - All actions register through
startos/actions/index.ts; the action files themselves are grouped intoaccounts/,email/, andother/subfolders matching their UI group. - All init steps register through
startos/init/index.ts. - All locale strings live in
startos/i18n/dictionaries/default.tsandtranslations.ts. - Pitfalls (scratch image, USER 1000, volume layout, JWT secret) are documented inline where they bite:
startos/utils.ts(passwd plant, shared env),startos/init/initVolumeLayout.ts(volume root mount + chown), andstartos/init/ensureSecret.ts(persistent JWT secret).