---
title: "Zammad to Zammad Migration: The Complete Technical Guide"
slug: zammad-to-zammad-migration-the-complete-technical-guide
date: 2026-08-26
author: Nachi
categories: [Migration Guide, Help Desk]
excerpt: "Technical guide to migrating between Zammad instances — covering backup/restore, API-to-API merges, Docker paths, and the edge cases that break real migrations."
tldr: "Use backup/restore for full Zammad clones. For selective moves or instance merges, use API scripting with import mode, external ID mapping, and delta sync."
canonical: https://clonepartner.com/blog/zammad-to-zammad-migration-the-complete-technical-guide/
---

# Zammad to Zammad Migration: The Complete Technical Guide


# Zammad to Zammad Migration: The Complete Technical Guide

Migrating from one Zammad instance to another is not one problem — it's at least four, depending on your deployment type and what you're trying to accomplish. The path you take depends on whether you're moving between self-hosted instances, switching between SaaS and self-hosted, consolidating multiple Zammad instances after an acquisition, or upgrading across major versions where in-place upgrades are risky.

Unlike migrating to a different platform like [Zendesk](https://clonepartner.com/blog/blog/zammad-to-zendesk-migration-the-technical-guide/) or [Help Scout](https://clonepartner.com/blog/blog/zammad-to-help-scout-migration-a-technical-guide/), Zammad-to-Zammad means the data model is identical on both sides. That doesn't make it simple. The execution path depends entirely on your goal, and picking the wrong one wastes days.

This guide covers every viable migration path, the API and database-level mechanics, version constraints, and the edge cases that cause silent failures during cutover.

**Verified against:** Zammad 6.x/7.x REST API and backup/restore tooling. Zammad's API behavior and backup scripts can change between major versions — verify against your installed version before proceeding.

For general migration planning, see [How to Export Data from Zammad](https://clonepartner.com/blog/blog/how-to-export-data-from-zammad-methods-api-limits-portability/) and [Zero-Downtime Help Desk Data Migration](https://clonepartner.com/blog/blog/zero-downtime-help-desk-data-migration/).

> [!WARNING]
> Zammad does **not** have a built-in Zammad-to-Zammad migration wizard. The Migration Wizard supports inbound imports from Zendesk, Freshdesk, OTRS, and Kayako only. For Zammad-to-Zammad, you use backup/restore, direct database operations, or API-based scripting. ([docs.zammad.org](https://docs.zammad.org/en/latest/migration/index.html))

## Why Teams Migrate Between Zammad Instances

The most common reasons for Zammad-to-Zammad projects:

- **Infrastructure change.** Moving from an aging self-hosted server to a new host, different cloud provider, or containerized deployment.
- **SaaS ↔ self-hosted switch.** Moving from Zammad's hosted SaaS to self-hosted for data sovereignty or compliance — or the reverse, to reduce operational overhead.
- **Instance consolidation.** Merging two or more Zammad instances (e.g., after an acquisition or departmental unification) into a single instance.
- **Major version upgrade.** Jumping across multiple major versions where in-place upgrades are risky, requiring a fresh install and data import.
- **Environment isolation.** Cloning production data into staging or QA environments for testing.

Each scenario has a different optimal path.

## The Four Migration Paths

| Path | Best For | Preserves IDs | Downtime | Complexity |
|---|---|---|---|---|
| **Backup/Restore** | Same-version host moves (package installs) | Yes | Minutes–hours | Low |
| **Database Dump + Restore** | Docker and source-code installs | Yes | Minutes–hours | Medium |
| **API-to-API Migration** | Instance consolidation, selective migration | No | Zero (with delta sync) | High |
| **Vendor-Assisted Export** | SaaS → self-hosted or vice versa | Yes | Hours | Low (vendor-dependent) |

### Path 1: Backup and Restore (Package Installations)

This is the simplest and most complete path when moving between self-hosted instances using package installations on the same database engine. It preserves everything: passwords, internal IDs, API tokens, and session data.

Zammad ships `zammad_backup.sh` and `zammad_restore.sh` under `/opt/zammad/contrib/backup/`. These produce a full dump of the PostgreSQL database and the Zammad file storage (attachments, configuration).

**Key constraints:**

- The backup is always a **full dump** — no incremental backup, no partial export. You cannot select specific tickets, users, or organizations. ([docs.zammad.org](https://docs.zammad.org/en/latest/appendix/backup-and-restore/index.html))
- These scripts **only work for PostgreSQL package installations**. They won't work for Docker Compose or source-code installs.
- You **cannot restore to an older Zammad version**. The target must be the same version or newer.
- System settings configured via environment variables are **not included** in the backup.

> [!WARNING]
> **PostgreSQL version compatibility is a hard constraint.** A dump produced by `pg_dump` from PostgreSQL 14 cannot be restored to a PostgreSQL 12 cluster. Check both source and target PostgreSQL versions before starting. If they diverge, upgrade the target PostgreSQL first or use logical replication tools that handle cross-version conversion. This failure mode is silent in some restore paths — you may get a successful `pg_restore` exit code with actual data missing. Verify row counts in key tables (`tickets`, `ticket_articles`, `users`) immediately after restore.

> [!NOTE]
> For the safest restoration path, install the **exact same Zammad version** on the target host before restoring. You can upgrade to a newer version after a successful restore. Skipping multiple major versions increases the risk of database migration failures during startup. ([docs.zammad.org](https://docs.zammad.org/en/latest/install/update.html))

**Elasticsearch/OpenSearch compatibility** also applies. Zammad 5.x supported Elasticsearch 7.x; Zammad 6.x+ requires Elasticsearch 8.x or OpenSearch 2.x. After a cross-version host move, verify that the target Elasticsearch version matches what your Zammad version expects before attempting the index rebuild. Mismatched versions cause the rebuild to fail with mapping errors, leaving search non-functional. ([docs.zammad.org](https://docs.zammad.org/en/latest/prerequisites/software.html))

#### Check Your Storage Backend

Zammad stores attachments either in the database (`Storage::DB`) or on the filesystem (`Storage::FS`). Check your current mode via the Rails console:

```ruby
zammad run rails r "p Setting.get('storage_provider')"
```

If moving to Zammad SaaS, you may need to convert filesystem attachments back to database storage or provide the filesystem archive alongside the SQL dump. If moving to another self-hosted target, ensure the target is configured for the same storage provider before restoring.

#### Execute the Backup and Restore

Use the official scripts rather than manual `pg_dump` commands — they handle application state and attachments together.

```bash
# On the source: create backup
/opt/zammad/contrib/backup/zammad_backup.sh

# Transfer backup files to target host
# On the target: stop Zammad, then restore
systemctl stop zammad
/opt/zammad/contrib/backup/zammad_restore.sh
```

> [!CAUTION]
> The restore script drops and recreates the database. Zammad's docs recommend at least **2x** the uncompressed backup size in free storage on the target. If you only have the compressed dump, plan for **3x**. ([docs.zammad.org](https://docs.zammad.org/en/latest/appendix/backup-and-restore/restore.html))

After restoration, run maintenance tasks:

```bash
# Clear the cache
zammad run rails r 'Rails.cache.clear'

# Rebuild Elasticsearch index
zammad run rake zammad:searchindex:rebuild
```

On large instances (100k+ tickets), the Elasticsearch rebuild can take hours. Search will be degraded until it finishes — plan for it.

**Stop Zammad's background workers during restore.** Zammad runs a scheduler and notification worker as separate processes. These can attempt to read or write database records during restoration, causing race conditions or corrupted job state. Use `systemctl stop zammad` (which stops all Zammad services including workers) and confirm all processes have exited before the restore starts.

**Spot-check row counts immediately after restore:**

```sql
-- Run via psql on the target after restore
SELECT 'tickets' AS table_name, COUNT(*) FROM tickets
UNION ALL SELECT 'ticket_articles', COUNT(*) FROM ticket_articles
UNION ALL SELECT 'users', COUNT(*) FROM users
UNION ALL SELECT 'organizations', COUNT(*) FROM organizations;
```

Compare these against the same query on the source. If counts diverge, the restore is incomplete regardless of what the script reported.

### Path 2: Database Dump + Restore (Docker and Custom Deployments)

If you're running Zammad via Docker Compose or a source installation, the built-in backup scripts won't work. You handle the database and file storage manually.

**Steps:**

1. Stop Zammad on the source to ensure data consistency.
2. Dump the PostgreSQL database using `pg_dump`.
3. Copy the attachment storage directory (typically `/opt/zammad/storage` or the Docker volume).
4. On the target: install the same Zammad version, restore the database, restore the storage directory.
5. Clear cache and rebuild Elasticsearch.

```bash
# Dump from source
pg_dump -Fc -U zammad zammad_production > zammad_dump.custom

# Restore on target
pg_restore -U zammad -d zammad_production zammad_dump.custom
```

> [!WARNING]
> Restoring backups can overwrite your `database.yml`. If your target instance uses different database credentials, save the original `config/database.yml` before restoring and re-apply it afterward.

**PostgreSQL version mismatch applies here too.** The `-Fc` (custom format) dump is the safest format for cross-host moves because it allows selective restore, but it does not solve version incompatibility. If source and target PostgreSQL versions differ, run `pg_dump --version` and `pg_restore --version` on both hosts and confirm the client version is compatible with the server version.

For **Docker Compose** specifically, Zammad documents a different path: backups live in the `zammad-backup` volume, a one-time backup can be triggered with `BACKUP_ONCE=true`, and restore is triggered by placing backup files under `/var/tmp/zammad/restore/`. The stack waits for the restore to finish before other containers continue. ([docs.zammad.org](https://docs.zammad.org/en/latest/appendix/backup-and-restore/docker-compose.html))

### Path 3: API-to-API Migration (Instance Consolidation and Selective Moves)

This is the path you need when backup/restore won't work — primarily when **merging multiple Zammad instances** into one, when you need to **selectively migrate** specific groups or date ranges, or when the target already contains data you cannot overwrite.

Zammad follows an "API First" philosophy — the UI itself is an API client. Every operation in the interface is accessible via the REST API at `/api/v1/`. This makes scripted migration fully viable, but it requires custom orchestration and careful handling of dependencies.

Community guidance from Zammad maintainers is direct: there is nothing in core for granular exports, there are no helper scripts for Zammad-to-Zammad ticket moves, and the practical route is custom work against the API. ([community.zammad.org](https://community.zammad.org/t/export-import-of-tickets-of-one-group/9471))

#### Enable Import Mode on the Target

This is the step most teams miss. Without import mode, Zammad fires events on every record creation — sending email notifications, triggering automations, and overwriting timestamps with the current time.

For **self-hosted** targets, enable via the Rails console:

```ruby
rails c
>> Setting.set('import_mode', true)
```

For **SaaS** targets:
- Trial accounts: email `enjoy@zammad.com` to request import mode.
- Paid accounts: email `support@zammad.com` to request import mode.

Import mode suppresses notifications, outbound emails, and event triggers. It also allows you to set `created_at` and `updated_at` timestamps on imported records, preserving the original timeline.

After the migration completes, disable it:

```ruby
>> Setting.set('import_mode', false)
>> Setting.set('system_init_done', true)
>> Rails.cache.clear
```

> [!TIP]
> Zammad 7.x also documents `X-Zammad-Suppress-Notifications: true` as a per-request header for agent/admin ticket updates and article creation. Verify this works on your exact version before relying on it in place of import mode. ([docs.zammad.org](https://docs.zammad.org/en/pre-release/api/ticket/))

**Stop Zammad's background workers during API import.** The scheduler and notification worker process jobs from the database queue. During import, they can pick up partially-created records and trigger automations, notifications, or SLA recalculations on incomplete data. On self-hosted instances, stop worker processes separately if you need the Zammad web process running to serve API requests:

```bash
# Stop only the worker, keep the web process running for API access
systemctl stop zammad-worker
# After migration completes
systemctl start zammad-worker
```

Verify the worker is stopped before loading tickets.

#### Create Custom Object Attributes and Ticket States First

If your source instance has custom ticket, user, or organization fields, these must be recreated on the target **before** importing any data that references them. Otherwise, the API silently drops custom field values.

Similarly, **custom ticket states** must be created before importing tickets that reference them. Zammad ships with a fixed set of default states (`new`, `open`, `pending reminder`, `pending close`, `closed`, `merged`). If your source instance has additional custom states (e.g., `on hold`, `waiting for vendor`), create these on the target first. Tickets imported with a state that doesn't exist on the target will either fail or default to an incorrect state without warning.

**Attribute migration steps:**

1. Export attribute definitions: `GET /api/v1/object_manager_attributes`
2. Filter for custom (non-default) attributes
3. Create each on the target: `POST /api/v1/object_manager_attributes`
4. Execute database migrations: `POST /api/v1/object_manager_attributes/execute_migrations`
5. **Restart Zammad on the target** — attribute changes require a restart to take effect

Depending on your Zammad version and the attribute type being created, you may also need to run `zammad run rake db:migrate` on the target host after the API migration call. Skipping this step causes the attribute column to be absent from the database, causing ticket creation to fail with an obscure ActiveRecord error. Compare `object_manager_attributes` across source and target before moving a single ticket. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/object.html))

#### Extraction Strategy

Zammad's API has **[hard limits on the number of returned objects per request](https://clonepartner.com/blog/blog/how-to-export-data-from-zammad-methods-api-limits-portability/)** that you cannot raise. Pagination is required for any non-trivial dataset.

```bash
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://source.example.com/api/v1/tickets?page=1&per_page=100&expand=true"
```

The `expand=true` parameter returns full object data instead of just IDs for related records. Without it, you get relationship IDs that require additional lookups.

For progress tracking, get total counts:

```bash
curl -H "Authorization: Token token=YOUR_TOKEN" \
  "https://source.example.com/api/v1/tickets/search?query=*&only_total_count=true"
```

For scoped exports, Zammad supports **condition-based search** using the same condition structure as triggers and overviews. You can build the condition in the UI, extract the JSON through the Rails console, and reuse it in API search requests:

```bash
zammad run rails r "puts Overview.find_by(name: 'Migration scope').attributes.slice('condition').to_json"
```

That pattern gives you a stable, repeatable export scope for staged backfills, delta passes, and final cutover QA. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/intro.html))

Use a **service account with broad admin visibility**. Ticket endpoints are permission-scoped by group access. If the export token cannot see an object, your migration script silently behaves as if the data does not exist. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/index.html))

**Implement idempotency and error capture from the start.** Multi-day API migrations will encounter transient failures — timeouts, rate limit responses, network interruptions. Write scripts that record the source ID and target ID of every successfully created record into a durable mapping store (SQLite or Redis). On restart, skip records already present in the mapping store. Happy-path scripts that re-run from scratch on failure will double-import records and corrupt the dataset.

Example pattern (pseudocode):

```python
for ticket in source_tickets:
    if mapping_store.exists(source_id=ticket['id']):
        continue  # already migrated, skip
    try:
        result = post_to_target('/api/v1/tickets', payload=transform(ticket))
        mapping_store.record(source_id=ticket['id'], target_id=result['id'])
    except RateLimitError:
        sleep(exponential_backoff())
        retry()
    except Exception as e:
        log_error(ticket['id'], e)
        continue  # log and continue; review failures after bulk run
```

#### Load Order and ID Mapping

When you create records via API on the target, **new IDs are assigned**. You must maintain a mapping table (Redis, SQLite, or a similar key-value store) throughout the migration:

```
source_org_id         → target_org_id
source_user_id        → target_user_id
source_group_id       → target_group_id
source_ticket_id      → target_ticket_id
source_ticket_state   → target_ticket_state  (if custom states exist)
```

Create records in dependency order:

1. **Organizations** (no dependencies)
2. **Groups** (no dependencies, but verify parent groups for nested structures — subgroups use `::` syntax)
3. **Users** (reference organizations)
4. **Custom Ticket States** (must exist before tickets that reference them)
5. **Custom Object Attributes** (must exist before tickets — then execute migrations and restart)
6. **Tickets** (reference users, groups, organizations, states)
7. **Articles** (reference tickets, users)
8. **Tags** (reference tickets)
9. **Knowledge Base** categories, then answers

> [!TIP]
> Deduplicate users by email address and organizations by name before creating on the target. If you're consolidating two instances, both likely share customers. Creating duplicate users fractures ticket history.

If you fail to map IDs correctly, tickets get orphaned or assigned to the wrong users — a data integrity issue that requires a full re-import to fix after the fact.

#### Preserving Timestamps

With import mode enabled, Zammad allows you to set `created_at` and `updated_at` on imported records. You must explicitly pass these in your POST payloads:

```json
{
  "title": "Server outage",
  "group": "IT Support",
  "customer_id": 145,
  "created_at": "2023-10-14T09:22:00Z",
  "updated_at": "2023-10-15T11:00:00Z"
}
```

Without import mode, all timestamps reflect the import time. There is no bulk fix — you'd need to re-run the entire import.

#### Handling Attachments and Inline Images

Zammad attaches files to **Articles**, not to the Ticket itself. When extracting an article from the source, parse the `attachments` array and download each file individually:

```
GET /api/v1/ticket_attachment/{ticket_id}/{article_id}/{attachment_id}
```

When creating the article on the target, re-upload attachments as base64-encoded strings:

```json
{
  "ticket_id": 204,
  "body": "Here is the log file you requested.",
  "type": "note",
  "internal": false,
  "attachments": [
    {
      "filename": "error.log",
      "data": "YmFzZTY0LWVuY29kZWQtc3RyaW5nLWhlcmU=",
      "mime-type": "text/plain"
    }
  ]
}
```

Base64 encoding increases payload size by ~33%. For large attachments (50MB+), this can cause timeouts. Implement retry logic with exponential backoff. Start with a timeout threshold of 120 seconds per request and increase if your instance consistently times out on large files.

**Inline images** are a separate trap. They're stored as attachments but referenced inside the article HTML via `/api/v1/ticket_attachment/...?view=inline`. After re-uploading to the target, you must **rewrite those HTML references** to point to the new target ticket/article/attachment IDs, or images break silently. This rewrite step is one of the most common misses in Zammad-to-Zammad API migrations.

Rewrite procedure:
1. After creating the article and its attachments on the target, collect the new attachment IDs from the API response.
2. Build a find-replace map: `source_ticket_id/source_article_id/source_attachment_id` → `target_ticket_id/target_article_id/target_attachment_id`.
3. Apply the map to the article HTML body before or immediately after creation (requires a PATCH to update the body).
4. Verify by fetching the article from the target and confirming all `src` attributes in `<img>` tags resolve correctly.

Skipping this rewrite produces articles that look complete in the article list but display broken images in the detail view, with no error logged anywhere. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/articles.html))

#### Article Sender and Authorship

When creating tickets on behalf of a customer via API, set `sender` to `Customer` explicitly. If you forget, the sender defaults to `Agent` and the ticket's contact timestamps shift. Article sender cannot be changed after creation. Use `origin_by_id` for article-level authorship on replay. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/index.html))

#### Knowledge Base Migration

Zammad's Knowledge Base API has a nested structure: Knowledge Base → Categories → Answers, each with separate translation objects.

- Categories: `GET /api/v1/knowledge_bases/{kb_id}/categories`
- Answers: `GET /api/v1/knowledge_bases/{kb_id}/answers/{answer_id}?include_contents={translation_id}`

To retrieve the actual body content of an answer, you need the `include_contents` parameter with the **translation ID** — not the answer ID. The translation ID is found in the `KnowledgeBaseAnswerTranslation` section of the answer response. Miss this detail and your KB export looks complete while article bodies are empty.

#### Authentication, Passwords, and 2FA State

Passwords cannot be extracted via the Zammad API — they are hashed in the database. In an API-to-API migration, users will not be able to log in with their old credentials.

**Two-factor authentication (2FA) state is also lost in API migrations.** TOTP seeds and device trust records are stored in the database linked to internal user IDs. When a user is recreated via API on the target with a new ID, their 2FA enrollment is severed. Users with 2FA enabled will be locked out after the migration and must re-enroll.

Plan for this before cutover:

1. Export a list of users with 2FA enabled from the source: `GET /api/v1/users?expand=true`, filter for records where two-factor fields are populated.
2. Notify these users before cutover that they will need to re-enroll 2FA.
3. After migration, provide a self-service 2FA setup link or have agents walk affected users through re-enrollment.

For credentials generally, choose one of two paths:

1. **SSO / SAML / LDAP:** If authentication is handled externally (Azure AD, Google Workspace, etc.), this is a non-issue. Ensure the `email` field matches exactly between source and target user records.
2. **Password resets:** If users rely on local Zammad credentials, trigger a mass password reset via the API once the migration completes.

### Path 4: SaaS ↔ Self-Hosted (Vendor-Assisted)

Zammad's documentation confirms that switching between SaaS and self-hosted is supported. For SaaS-to-self-hosted moves, Zammad support provides a data dump (database + attachments) that you restore onto your self-hosted instance. ([support.zammad.com](https://support.zammad.com/help/en-us/55-storage/26-can-my-system-be-restored-to-a-previous-state))

**Process:**

1. Contact Zammad support (`support@zammad.com` for paid accounts) to request your data export.
2. Install the same Zammad version on your target infrastructure.
3. Restore using the provided dump files following the standard restore procedure.
4. Rebuild Elasticsearch index, clear cache, verify.

For self-hosted-to-SaaS moves, Zammad support can restore a PostgreSQL dump and filesystem archive into their SaaS environment, provided the storage backends and versions align.

**After any SaaS-to-self-hosted move, audit these integrations manually:**

- **Slack / Microsoft Teams:** Webhook URLs and bot tokens are tied to the originating instance URL. Regenerate and reconfigure.
- **GitHub / GitLab / Jira:** OAuth tokens are URL-scoped. Re-authenticate each integration under the new domain.
- **Email channels:** IMAP/SMTP credentials carry over in the database but the new instance URL must be reflected in any SPF/DKIM/DMARC records for outbound mail.
- **Webhooks:** All outbound webhooks defined in Zammad contain the source instance's callback URL if used for round-trips. Review and update each webhook endpoint.

These items are not restored automatically regardless of migration path. Skipping this audit causes silent integration failures after cutover.

## Cutover Strategy

Whether using backup/restore or API scripting, the cutover phase requires strict orchestration to prevent data loss.

### For Backup/Restore

Database restores are point-in-time. Any ticket created on the source after the backup starts is lost.

1. Announce a maintenance window.
2. Activate maintenance mode and disable email channels on the source.
3. Take the backup.
4. Restore to the target.
5. Update DNS and mail forwarding rules to point to the new instance.
6. Enable email channels on the target.
7. Rebuild Elasticsearch and verify.

### For API Migrations (Delta Sync)

API migrations allow for delta sync, minimizing downtime:

1. **Freeze schema changes.** Stop changing custom fields, triggers, group structures, and SLA logic during migration.
2. **Preseed reference data.** Create groups, states (including custom states), calendars, SLAs, organizations, agent accounts, custom fields, and KB structure before loading tickets.
3. **Backfill cold history first.** Load closed tickets and older articles before touching active queues.
4. **Run delta passes.** Query the source for tickets modified after the last sync timestamp. Migrate only new and updated records.
5. **Cut inbound channels late.** Switch mailboxes, forms, webhooks, and integrations only when the target is ready.
6. **Run final QA by sample, not just counts.** Check one ticket per group, state, and custom-field pattern. Verify internal notes, attachments, inline images, mentions, linked tickets, and KB bodies.
7. **Reindex and release agents.** Search validation comes last because restore-based moves show degraded search until reindexing finishes.

**Sample QA queries for spot-checking record integrity:**

```bash
# Verify ticket count on target matches expected range
curl -H "Authorization: Token token=TARGET_TOKEN" \
  "https://target.example.com/api/v1/tickets/search?query=*&only_total_count=true"

# Pull a specific migrated ticket by mapped ID and verify key fields
curl -H "Authorization: Token token=TARGET_TOKEN" \
  "https://target.example.com/api/v1/tickets/{target_id}?expand=true" \
  | jq '{title, state, group, created_at, article_count: .article_ids | length}'

# Verify article count matches source
curl -H "Authorization: Token token=SOURCE_TOKEN" \
  "https://source.example.com/api/v1/ticket_articles/by_ticket/{source_id}" \
  | jq 'length'
```

For database-level verification after backup/restore:

```sql
-- Check article counts per ticket match between source and target
SELECT ticket_id, COUNT(*) AS article_count
FROM ticket_articles
GROUP BY ticket_id
ORDER BY article_count DESC
LIMIT 20;

-- Spot-check attachment presence
SELECT ta.id, ta.filename, ta.size
FROM store_objects so
JOIN stores s ON s.store_object_id = so.id
JOIN ticket_articles ta ON ta.id = s.o_id::integer
WHERE so.name = 'Ticket::Article'
LIMIT 50;
```

## Edge Cases and Failure Modes

These are the issues that derail teams mid-migration:

- **Skipping the Elasticsearch rebuild.** After a database restore, search returns incorrect or incomplete results. The UI will look broken until the rebuild completes.
- **Elasticsearch version mismatch.** Zammad 5.x requires Elasticsearch 7.x; Zammad 6.x+ requires Elasticsearch 8.x or OpenSearch 2.x. A mismatched version causes rebuild failures with mapping errors. Verify compatibility before starting the rebuild.
- **PostgreSQL version mismatch.** A `pg_dump` from PostgreSQL 14 cannot restore to PostgreSQL 12. Confirm source and target PostgreSQL versions before the migration. Verify row counts in key tables immediately after restore — some version mismatches produce successful exits with data loss.
- **Ticket number collisions.** When consolidating two instances, both may have tickets with the same number (e.g., `#10001`). Zammad enforces unique ticket numbers. During API import, the target assigns new numbers — accept this and maintain a mapping table.
- **Permission-scoped exports.** Ticket and mention endpoints hide records when the API token lacks the right group visibility. Always use an admin-level service account. ([docs.zammad.org](https://docs.zammad.org/en/latest/api/ticket/index.html))
- **Notification storms.** Forgetting import mode means every imported ticket fires automations and sends emails. On a 50k-ticket import, this is catastrophic.
- **Trigger side effects on closed tickets.** Zammad's triggers may reopen closed tickets when articles are POSTed via API. Use import mode to suppress event processing during the migration.
- **Broken inline images.** Inline images depend on rewritten attachment paths. Skip the HTML rewrite and images appear as broken references with no error logged.
- **System user collisions.** Zammad relies on a system user (usually ID 1) for automated actions. Mapping tickets to ID 1 during an API migration can trigger unintended automation behavior.
- **Inherited workflows.** Backup/restore carries over all Core Workflows, triggers, macros, SLAs, and scheduler jobs. If you intended to restructure these on the target, you'll inherit the source config wholesale. For API migration, these configuration objects must be recreated manually.
- **Custom ticket states not preseeded.** Tickets imported before their referenced states exist will fail or default silently. Custom states must be in the load order before tickets.
- **Object manager migration not executed.** Creating custom attributes via API is insufficient without calling `POST /api/v1/object_manager_attributes/execute_migrations` and restarting Zammad. In some versions, `zammad run rake db:migrate` is also required. Missing this step causes silent data loss on any custom field.
- **2FA re-enrollment required.** Users with TOTP-based two-factor authentication will be locked out after an API migration. Identify and notify these users before cutover.
- **Background worker race conditions.** Workers processing jobs during database restore or API import can write to partially-populated tables. Stop background workers before starting any migration operation.
- **Integration tokens invalidated.** OAuth tokens for Slack, GitHub, Jira, and similar integrations are scoped to the source instance URL. These require manual regeneration after any domain change.

## Time Estimation

| Scenario | Estimated Time | Primary Bottleneck |
|---|---|---|
| Host migration (backup/restore) | 30 min – 4 hours | Database dump/restore + ES rebuild |
| SaaS → self-hosted (vendor dump) | 1 – 8 hours | Waiting for vendor + restore + ES rebuild |
| API migration (~10k tickets) | 4 – 12 hours | Attachment download/upload |
| API migration (~100k tickets) | 2 – 5 days | API throughput + attachments |
| Instance consolidation (2 instances) | 3 – 10 days | Deduplication + conflict resolution |

The biggest variable in API migrations is always **attachments**. A 50k-ticket instance with heavy attachment usage can have 100GB+ of binary data that must be downloaded and re-uploaded one article at a time.

**Rate limiting:** Self-hosted instances are limited only by your hardware. Zammad's cloud (SaaS) infrastructure employs reverse proxies that return HTTP 429 when request thresholds are exceeded. Throttle concurrency to 3–5 parallel requests against SaaS targets. Build retry logic with exponential backoff regardless of deployment type — network-level rate limiting does not always return a 429; some configurations drop connections silently.

## Which Path Should You Choose?

**Use backup/restore when:**
- Moving to a new host with the same (or newer) Zammad version
- Package installation on both sides
- PostgreSQL version is the same (or you've verified compatibility)
- You want a complete, identical copy including IDs and passwords

**Use database dump/restore when:**
- Docker or source-code installation
- Same database engine and compatible PostgreSQL versions
- You need a full-fidelity copy including IDs

**Use API-to-API when:**
- Consolidating multiple instances
- You need selective migration (specific groups, date ranges, custom states)
- The target already contains data you cannot overwrite
- You want zero-downtime migration with delta sync
- You need to restructure workflows, triggers, or group structure during migration

**Contact Zammad support when:**
- Moving from SaaS to self-hosted (or vice versa)
- You don't have direct database access

For instance consolidation — the hardest variant — the API path is the only viable option. There is no way to merge two database dumps. This is where teams underestimate the effort: deduplicating shared customers, resolving ticket number conflicts, reconciling different custom field schemas and custom ticket states, rewiring inline image references, re-enrolling 2FA users, rebuilding integration tokens, and merging knowledge bases with overlapping category structures. Treat it as a full ETL project: extract everything to local storage, transform the payloads and map the IDs, and load sequentially with robust idempotent error handling.

## When to Bring in Help

Backup/restore for a simple host move is straightforward if you follow the docs and verify PostgreSQL and Elasticsearch version compatibility upfront. API-based consolidation of two production Zammad instances with 50k+ tickets, custom objects, custom states, and knowledge bases is a different category of problem — the failure modes are numerous, the debugging is time-consuming, and silent data loss is the primary risk.

At ClonePartner, we've handled Zammad migrations across all four paths — including multi-instance consolidations where both source instances had divergent custom field schemas, custom ticket states, and overlapping customer databases. We handle the extraction scripting, ID mapping, deduplication logic, attachment transfer, inline image rewrites, 2FA impact assessment, integration reconfiguration, and validation so your team can focus on the cutover plan.

> Need help with a Zammad instance migration, consolidation, or SaaS-to-self-hosted move? Book a 30-minute technical scoping call with our engineering team.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Does Zammad have a built-in Zammad-to-Zammad migration tool?

No. Zammad's Migration Wizard supports inbound imports from Zendesk, Freshdesk, OTRS, and Kayako only. For Zammad-to-Zammad, you use backup/restore scripts (package installations), direct database operations, or custom API scripting depending on your scenario.

### Can I merge two Zammad instances using a database backup?

No. Database restores overwrite the target completely and will cause primary key collisions. To merge instances, you must extract data via the REST API, deduplicate shared users and organizations, and load into the target with a maintained ID mapping table.

### Can I restore a Zammad backup to a different version?

You can restore to the same or a newer version, but not to an older one. The safest path is to install the exact same version as your source, restore, then upgrade incrementally. Skipping multiple major versions increases the risk of database migration failures.

### What is Zammad import mode and why do I need it?

Import mode (Setting.set('import_mode', true) via Rails console) suppresses notifications, outbound emails, and event triggers during data import. It also allows setting created_at and updated_at timestamps on records. Without it, every imported ticket fires automations, sends emails, and all timestamps reflect the import time instead of the original creation time.

### Will passwords migrate during an API-to-API Zammad migration?

No. Passwords are hashed in the database and cannot be extracted via the API. Users will need to reset their passwords, or you must rely on an external SSO/SAML/LDAP provider where the email address is the matching key.
