Redmine is good software. That is not why people leave.
Redmine's first release was in 2006, and plenty of teams have run it happily ever since. It is free, it is self-hosted, the data model is sensible, and the issue list has done its job every working day for over a decade without asking anyone for a credit card. Nobody abandons Redmine because it stopped tracking issues.
They leave because of what has accumulated around it: the plugin that pins the instance to an old version, the Ruby toolchain rebuilt after every OS upgrade, the commercial fork quoting per-user pricing for features that used to arrive as plugins, and a REST API designed before anyone wanted a tracker driven by something other than a browser.
Then the migration question arrives and the plan stalls. Fifteen years of issue history, wiki revisions, attachments and logged time is not something you paste into a CSV and hope for the best.
TL;DR: Specivo ships a Redmine importer that reads Redmine's database directly (MySQL or PostgreSQL) through a read-only user and copies its attachment files, rather than going through the REST API. It carries projects and hierarchy, users and memberships, issues with their full journal history including private notes, wiki pages with every revision and the redirects left by renames, attachments and logged time. You start with
--dry-run, which performs the entire import inside a transaction and rolls it back, so its report previews real decisions instead of predicting them. Passwords, groups-as-groups, role permissions and modules Specivo does not have (repositories, forums, news, documents, calendars, Gantt) do not come across, and the importer lists each one instead of guessing.
Five reasons teams actually leave Redmine
1. The plugin treadmill
Redmine's plugin model is Rails monkey-patching. A plugin reopens core classes and prepends modules onto them, which works well right up until Redmine or Rails moves underneath it. The upgrade then stops being a Redmine upgrade and becomes an audit of every plugin you ever installed, in dependency order, against a Ruby version your distribution may have already dropped. You still find instances frozen several major versions back, on a Ruby that stopped getting security patches, because one unmaintained plugin holds everything in place and nobody has a spare fortnight to port it.
2. The stack underneath is a 2006 stack
Running Redmine properly means a Rails app, Bundler, native gem builds against system libraries, a Passenger or Puma process, a database, a mail relay and cron entries for the scheduled work. Each of those is fine. Together they are why redmine docker, bitnami redmine and "how to install Redmine on Ubuntu" have been steady search traffic for a decade: people keep doing this from scratch, and it keeps costing an afternoon.
Specivo ships as a single container image (specivo/specivo) with Postgres and Redis alongside it. Not a moral victory over Rails, just fewer moving parts to keep patched.
3. Core stopped moving, so the commercial forks moved instead
Redmine's feature set has been stable for a long time, and the gap has been filled by commercial forks and paid plugin vendors. That is how a free GPL tracker ends up in a per-user, per-month conversation. Easy Redmine, Lychee Redmine and the RedmineUP bundles exist because the features teams want next are not in core and are not coming.
For some teams that is the right answer. For others, discovering that Gantt charts, resource management and agile boards now sit behind three separate subscriptions is the moment they start looking at what else is self-hostable. Redmine's ticket for native recurring issues has been open since 2008; if your workflow depends on recurring work you are either buying a plugin or maintaining a cron job that files issues over the API. (Specivo treats recurring issues as a first-class entity for exactly this reason.)
4. The REST API has a ceiling
Redmine's REST API is fine for scripting. It is not fine as the system of record's only programmatic surface. It cannot see private notes. It cannot see the accounts of users who have been removed. It does not hand you complete history in a shape worth reconstructing, and for an instance of any size it is one HTTP request per issue, per journal entry, per attachment. That is not an academic complaint: it is why Specivo's importer does not use it.
5. Every modern capability arrives as somebody else's cloud
Teams still on Redmine in 2026 are usually there for a reason they can state in one sentence: they want to run their own tracker and keep their own data. What has quietly eroded is the second half. Meaning-based search means an embedding API. Agent access means shipping ticket text to a hosted model behind a wrapper you wrote. Each is a small exception to the policy that put you on a self-hosted tracker in the first place, and the exceptions add up. That trade gets its own section below.
A migration that drops history is not a migration
The usual "migration path" out of any tracker is a CSV export of the current state of each issue: subject, status, assignee, done ratio. That is a snapshot, and it destroys the thing that made the old instance valuable.
What has to survive:
- Issue history. Who changed what, when, and what it was before. This is where the answer to "why is this configured like this" lives, four years after the person who did it left.
- Private notes. They sit in the journal like everything else, and dropping them silently is worse than failing loudly.
- Wiki revisions. Every revision with its author and date, plus the redirects left behind by renames, so links in four years of issue descriptions still resolve.
- Attachments. The actual bytes, on the right issue and the right wiki page.
- Logged time. Somebody billed it, and the numbers have to still add up afterwards.
- Cross-references. Redmine writes
#123; Specivo writesACME-15. Every one has to be rewritten, and the ones pointing outside the import left alone rather than mangled into a broken link.
Why the importer reads the database, not the REST API
Specivo's importer connects to Redmine's database with a read-only user and reads the attachment directory through a read-only mount. Nothing is written to the source at any point.
Private notes and removed users are invisible to the REST API, so an API-based importer cannot produce a complete migration however carefully it is written. Reading the database gets a complete, single-pass extraction, and it is faster by orders of magnitude on an instance with real history. The cost is database and filesystem access to the Redmine you are leaving, which anyone migrating their own tracker has.
Both databases Redmine runs on are supported. MySQL sources need one extra dependency:
uv pip install 'specivo[importers]'
Always start with a dry run
make import-redmine ARGS='\
--source-db-url postgresql://redmine:[email protected]/redmine \
--source-files-dir /mnt/redmine-files \
--dry-run'
A dry run performs the whole import and rolls it back. Nothing is written and no files are copied.
It is not a separate preview path estimating what an import would do. It is the import, through the same services and the same validation, inside one transaction rolled back unconditionally at the end. A predictor drifts from the real thing over time; a rollback cannot.
The report is the deliverable, and three parts of it are worth reading closely.
Project keys. Redmine identifies an issue as #123. Specivo identifies it as ACME-15, and Redmine has no per-project prefix to derive that from. A key is generated from each project's identifier (uppercased, non-alphanumerics stripped, suffixed on collision) and every one is listed. Override the ones you dislike:
--project-key-map acme-app=ACME,internal-ops=OPS
Status categories. Redmine records only whether a status closes an issue. Specivo groups statuses into backlog, active, done and closed, so anything beyond open/closed is inferred from the status name. Correct it:
--status-category-map "Awaiting Review=active,Verified=done"
Everything that could not be carried across. Modules with no equivalent, custom fields defined on users or projects, attachments whose file is missing from disk. Each is listed with enough detail to go and check.
One caveat: the report previews the decisions, not a locked-in plan. Anything whose uniqueness depends on rows the dry run itself created, such as a suffixed login after a name collision, can come out differently on the real run, which starts from a clean target.
What comes across
| In Redmine | Arrives in Specivo as |
|---|---|
| Projects and subprojects | Projects with their hierarchy, each with a derived or mapped key |
| Users, account status, memberships | Users and per-project memberships |
| Issues, subtasks, watchers | Issues with parent links and watchers |
| Issue relations | Normalised from Redmine's nine spellings to five canonical types, direction inverted where that is what the spelling meant |
| Journals, including private notes | Full issue history, in the order the source recorded it |
| Custom fields on issues | Metadata schemas, with the JSON Schema type inferred per field format |
| Wiki pages | Every revision with its own author and date, plus rename redirects |
| Attachments on issues and wiki pages | Files copied from the attachments directory, digest recomputed on arrival |
| Logged time | Time entries |
| Textile markup | Markdown, with unconvertible fragments preserved verbatim |
#123 references | ACME-15, rewritten once every issue in the run exists |
What does not come across, and why
Some things have no Specivo equivalent. The importer says so rather than guessing at one.
| Not carried across | Why | What happens instead |
|---|---|---|
| Passwords | Redmine hashes with salted SHA1, Specivo with bcrypt | Every account gets an unusable password and is listed. Those people use password recovery, or an admin resets them |
| Groups, as groups | Specivo cannot hang a project role off a group | The group's grant is expanded per member. Access matches Redmine exactly; what is lost is that it came from a group |
| Role permissions | Redmine's permission vocabulary is its own, and an approximate translation would quietly widen or narrow access | Roles are matched by name. Anything new arrives with no permissions, listed for an admin to fill in |
| Repositories, forums, news, documents, calendars, Gantt | No Specivo equivalent | Dropped and counted per project |
| Custom fields on users, projects, versions, time entries | Specivo's metadata schemas target issues | Counted and dropped |
| Per-project priority and activity overrides | Specivo keeps both global | Filtered out and counted |
| Who logged a time entry, vs whose time it is | Redmine tracks both, Specivo keeps only the latter | The entry keeps the person it belongs to |
Publishing that table is deliberate. Every row is a conversation you would otherwise have three weeks after the migration, when somebody notices their group-based access looks different in the admin screen.
Three more things arrive, but changed. Logins and emails that collide with an existing account are suffixed (jsmith-2) rather than skipped, and listed. Logged time is rounded to two decimal places; float noise from Redmine's own storage (7.5 stored as 7.499999999999999) is rounded silently, while a rounding that discards precision somebody actually entered is reported. And a custom field that is required in Redmine but has existing issues with no value for it is imported as optional, because keeping it required would make the imported data invalid against its own schema on arrival.
Running the import for real
Drop --dry-run once the report looks right:
make import-redmine ARGS='\
--source-db-url postgresql://redmine:[email protected]/redmine \
--source-files-dir /mnt/redmine-files \
--project-key-map acme-app=ACME \
--report-json /tmp/import-report.json'
The run prints an identifier. If it is interrupted, pass it back:
make import-redmine ARGS='--source-db-url ... --resume 79309b3d-25a0-42df-a7ec-cf0be57b1ca1'
The import runs as nineteen phases and each commits as it finishes, so a failure in wiki attachments does not discard the projects, issues and journals that already landed. Resume picks up from the first phase with work left, deriving that from what is already in the database rather than from a state file that might disagree with it. Every entity the importer creates is recorded in an identifier map written in the same transaction as the row it describes, so either both exist or neither does. That is what makes re-running a finished import safe: everything is recognised and skipped.
Options worth knowing
| Option | What it does |
|---|---|
--project 1,2 | Import only these Redmine project ids. Subprojects come with their parent |
--merge-duplicate-statuses | Reuse an existing status, tracker or priority of the same name instead of creating a second |
--flatten-excess-depth | Import projects nested deeper than Specivo allows by re-parenting them higher up |
--cf-key-map "Story Points=points" | Choose the metadata key a custom field is stored under |
--source-instance name | Name this Redmine, so two installations can be imported into one Specivo |
--report-json path | Write the report as JSON as well as printing it |
--batch-size n | Rows read per page from the source (default 500) |
The importer streams and pages on the source's own primary key rather than materialising tables, so instance size is a time cost rather than a memory one.
What you stop renting
Back to reason five. Three of Specivo's capabilities exist because the alternative was an external service, and for a team that self-hosts on purpose, an external service is a policy exception.
An MCP server with 40+ tools, and conventions to go with it
Specivo ships an MCP server over HTTP, authenticated with bearer tokens issued as scoped API keys. It exposes 40+ tools: creating, updating, showing, listing, moving and commenting on issues; creating, reading, editing, appending to, restoring and doing section-level reads and replaces on wiki pages; search; sprints; versions; tags; relations; time logging; recurring patterns; metadata schemas; and the lookup tables an agent needs to pick valid tracker, status and priority ids.
Redmine has a REST API and community MCP wrappers for it exist, so this is not impossible-versus-possible. The difference is what you own afterwards:
- It is permission-aware. The agent's key carries scopes and belongs to a user, so the agent sees exactly what that user is allowed to see. No shared admin token standing in for everybody.
- The granularity is right. Section-level wiki reads and replaces mean an agent updating one heading does not pull a 4,000-word document through a context window and write it all back. Metadata is a structured operation, not free text parsed out of a description.
- It is version-matched to the instance. When the data model changes the tools change with it, rather than your wrapper finding out in production.
Getting an agent from zero to filing issues correctly is a symlink. specivo-agent-skills is a public, MIT-licensed repo holding one vendor-neutral source (shared/conventions.md) that scripts/build.py renders into per-client wrappers: claude/SKILL.md in the Claude Agent Skills format, codex/AGENTS.md, and cursor/.cursorrules.
ln -s "$(pwd)/claude" ~/.claude/skills/specivo
Claude discovers the skill and decides when to load it from the frontmatter description. Codex references codex/AGENTS.md from the project's own AGENTS.md; Cursor copies .cursorrules; GitHub Copilot CLI reads the same Agent Skills format, so the claude/ folder works there unchanged. Pointing Codex at the server is four lines:
[mcp_servers.specivo]
enabled = true
url = "https://your-specivo.example.com/mcp/"
[mcp_servers.specivo.http_headers]
Authorization = "Bearer spv_your_api_key_here"
Two details separate this from a demo. The server serves its own guidance: specivo_setup_guide(format="claude"|"cursor"|"generic") returns the tool list from your instance at your version, and the skill tells agents to call it once per session, so the conventions cannot rot when Specivo ships new tools.
One service account per agent is the documented default, not a hardening tip. You create claude-code, codex and cursor as separate users, scope each to the projects it needs, and issue a key from each account; each agent reads its own user_id from specivo_whoami and self-assigns. You get a separable audit trail — the activity feed shows which agent did what — plus independent revocation and per-agent permissions. That is only viable because Specivo has no per-seat pricing. On a tool billed per user, giving each agent its own identity is a line item, so teams skip it, every agent shares a human's credentials, and the audit trail turns to mush. It is the same arithmetic that pushes Redmine teams toward the commercial forks, arriving one layer further down.
Semantic search that runs on your own CPU
Search is hybrid: Postgres full-text for keywords, pgvector for meaning. The semantic half runs a local ONNX model, multilingual-e5-small, roughly 393 MB, downloaded once with make download-model and stored under SPECIVO_DATA_DIR. Inference happens on CPU through ONNX Runtime with no external calls, and EMBEDDING_ONNX_THREADS controls how much CPU it may use.
Two limits, stated plainly. Embedding models are a first-class database record (provider, model name, dimensions, query and document prefixes) with an admin API to register one and backfill embeddings across the corpus, so the model is swappable and the index rebuildable against a new one. What ships is local inference: pointing the embedder at a hosted provider is scaffolded but not implemented. Read it as "run a model you host yourself", not "pick any model you like".
The multilingual part matters more than it sounds. Redmine's own search demand is heaviest in Japan, France, Brazil, Taiwan and Germany, a fair proxy for how many Redmine instances are full of non-English issues. A tracker with fifteen years of tickets in Japanese gets meaning-based search over them without any of that text leaving the building. Skip the model download and keyword search still works normally; the only thing you lose is semantic matching.
An air-gapped install that is genuinely air-gapped
For the defence, finance and industrial teams who are on Redmine precisely because it runs on their own metal, this is the section that matters. Specivo needs four images:
TAG=0.1.10
docker pull specivo/specivo:$TAG
docker pull pgvector/pgvector:pg18
docker pull redis:7-alpine
docker pull nginx:1-alpine
docker save \
specivo/specivo:$TAG \
pgvector/pgvector:pg18 \
redis:7-alpine \
nginx:1-alpine \
-o images.tar
Carry images.tar and a clone of the repo (for the compose file and nginx config) across the gap, then on the offline host:
docker load -i images.tar
echo 'SPECIVO_VERSION=0.1.10' >> .env
docker compose up -d
Migrations and default seeding run from inside the loaded image, so there is nothing to fetch at first boot. Pre-fetch the embedding model on the connected machine, drop it into SPECIVO_DATA_DIR, and semantic search works offline too.
At runtime Specivo needs no outbound internet at all. You go online again only to fetch a newer image for an upgrade, and GitHub Releases carry an offline deployment bundle for that. Note the version pin: specivo/specivo is pinned to a release tag rather than latest, so what boots on the offline host is the version you tested on the connected one. Full procedure in the air-gapped install guide.
After the import
- Reset the imported accounts, or tell those people to use password recovery.
- Give any newly created roles their permissions. They arrive empty on purpose.
- Check the flattened group memberships against how the group was actually used.
- Read the JSON report once more, now that the numbers are real.
FAQ
How do I migrate from Redmine without losing issue history?
Use an importer that reads Redmine's database rather than its REST API. The journal table holds the full history including private notes, and the API does not expose all of it. Specivo's importer reads the database with a read-only user and replays journals in the order they were recorded.
Does the importer write anything to my Redmine?
No. It needs a read-only database user and a read-only mount of the attachments directory. Nothing is written to the source at any stage, including during a dry run.
Does it work with MySQL, or only PostgreSQL?
Both. MySQL sources need one extra dependency (uv pip install 'specivo[importers]'). Both backends are tested against a real seeded Redmine instance, because the two schemas are only identical in theory.
What happens to Redmine passwords?
Nothing portable. Redmine hashes with salted SHA1 and Specivo uses bcrypt, so every imported account arrives with an unusable password and is listed in the report. Those people use password recovery, or an administrator resets them.
What if the import fails halfway through?
Each of the nineteen phases commits as it finishes, so everything up to the failure is kept. Re-run with --resume <run-id> and it continues from the first phase with work left. Re-running a finished import is safe, because every created entity is recorded in an identifier map written in the same transaction as the row itself.
What happens to Textile markup?
It is converted to Markdown, and anything the converter cannot handle is preserved verbatim rather than dropped. CommonMark has been the default for new installations since Redmine 5.1, so instances already on Markdown get link rewriting only.
Does semantic search send my issues to a third party?
No. The embedding model runs locally on CPU through ONNX Runtime with no external calls. It is a one-off ~393 MB download, and on an air-gapped host you copy the file across yourself. Skipping it leaves keyword search fully working.
Can Specivo run with no internet access?
Yes. docker save the four images on a connected machine, carry them and the repo across, docker load, pin SPECIVO_VERSION, and bring the stack up. Migrations and seeding run from inside the loaded image, and at runtime nothing reaches out.
What is the difference between Redmine and Easy Redmine?
Redmine is GPL and free. Easy Redmine is a commercial product built on top of it, sold in paid tiers priced per user, with its own feature set and upgrade cycle. Teams usually meet it when they go looking for Gantt charts, resource management or agile boards and find those are no longer a plugin away.
Should I move to Jira instead?
That is a different decision, not a bigger version of the same one. Jira is hosted-first and priced per user per month, so leaving a free self-hosted GPL tracker for it trades one set of constraints (you run it) for another (you rent it, and your data lives elsewhere). If self-hosting was why you were on Redmine, compare self-hosted options first.
Is Redmine still worth running in 2026?
For a lot of teams, yes. If your instance is upgraded, your plugin set is small and maintained, and nothing in your workflow is waiting on a feature that is not coming, there is no urgent reason to move. The case for leaving is strongest when a plugin pins you to an old version, when you are paying a fork for features that used to be free, or when you need to give AI agents structured access to a tracker designed before that was a use case.
Where to start
- Stand up a Specivo instance. The Docker install guide takes about ten minutes and does not touch your Redmine.
- Point a dry run at a copy of your Redmine database, or at the live one with a read-only user. Read the report.
- Fix the project keys and status categories you disagree with, then dry-run again.
- Run it for real.
The full operator guide, with every flag and the post-import checklist, is at Migrating from Redmine.
Redmine earned its fifteen years, and for plenty of teams it has a few more in it. If yours is done, the history should come with you.