Skip to content

Trash: soft delete and deletion protection ​

Deleting an artifact, a version, a Docker tag or a whole repository does not destroy it. The item moves to the trash, disappears for every client, and stays restorable for a retention period (7 days by default). A background worker then removes it for good. Repositories can opt out of the delay, or turn on immutable_releases to protect released versions.

What is soft-deleted ​

Everything a delete can remove, in every format, plus repositories:

  • Package rows of the hosted formats: Maven, Ivy, sbt, npm versions, PyPI, NuGet, Go modules, Cargo, RubyGems, Conda, Conan, Alpine packages, Terraform modules and providers, Helm, raw files, Docker manifests and tags, RPM, Arch Linux (pacman) and Debian packages.
  • Whole repositories.
  • Items removed by a cleanup policy.

Deleting a Docker manifest takes every tag that points to it into the trash in the same step, and restoring the manifest brings those tags back.

A trashed item is not copied or renamed: it is the same record with a deleted_at timestamp, and its blob stays where it is. Everything except the trash itself behaves as if the item did not exist:

AreaBehavior while an item is in the trash
Native protocols404, like any missing path or version
Listings, UI, search, counts and statisticsthe item is absent
Generated indexes (Debian Packages, RPM repodata, Helm index.yaml, npm packuments, Maven maven-metadata.xml, PyPI index, ...)rebuilt without the item; metadata is regenerated on delete and again on restore
Proxy and group repositoriesa trashed hosted item is not served through a group; a trashed repository is not a group member
Uniquenessthe path or version can be published again while the old copy is in the trash (see restore conflicts)
Blobskept until purge; storage usage does not drop on delete
Quotatrashed bytes keep counting (below)
Authorization cachesee below

Multiple replicas and the auth cache ​

Repository, project and membership lookups made while authorizing a request are cached in memory for DB_AUTH_CACHE_TTL_MS (default 2000 ms). Trashing or restoring a repository through a replica invalidates that replica's cache at once, but the other replicas keep their cached copy until the TTL expires. For up to that long, another replica can still serve a just-trashed repository (and keep refusing a just-restored one). The same applies to changes made directly in the database. Lower the TTL, or set it to 0, if that window is not acceptable; see also RBAC.

Retention ​

SettingEnvironment variableYAML keyDefault
Server-wide retention in daysOPENARTIFACT_TRASH_RETENTIONtrash.retentionDays7
Pause between purge passesOPENARTIFACT_TRASH_PURGE_INTERVALtrash.purgeInterval15m

The full rows are in configuration. Setting the retention to 0 turns the trash off: deletes are immediate and permanent, as before the feature existed. trash.purgeInterval: 0 disables the scheduled purge (items then stay until an admin purges them by hand).

There is deliberately no OPENARTIFACT_REPO_DELETE_DELAY. The trash window is the deletion delay: a deleted repository is hidden at once, can be restored for the retention period and is removed afterwards.

Per-repository override ​

Every repository has a trash_retention_days setting that applies to deletes of its content and to deleting the repository itself:

ValueMeaning
null or omitteduse the server-wide retention
N >= 1keep deleted items N days
0delete immediately and permanently (the behavior before the trash)
sh
curl -X PATCH HOST/api/v1/repository/builds -H "Authorization: Bearer <TOKEN>" \
  -H 'Content-Type: application/json' -d '{"trash_retention_days":30}'

# CI scratch repository: no safety net, free space at once
curl -X PATCH HOST/api/v1/repository/ci-scratch -H "Authorization: Bearer <TOKEN>" \
  -H 'Content-Type: application/json' -d '{"trash_retention_days":0}'

Also available at creation (POST /api/v1/repositories), in the repository's Settings tab and in the CLI: oa repo create|update --trash-retention-days N (default on update goes back to the server-wide value). Negative values are 400.

Kinds ​

Trash items are addressed by kind and id (both shown by the list endpoints).

KindWhat it is
repositorya whole repository
maven_artifact, ivy_artifact, sbt_artifacta file in a Maven, Ivy or sbt repository
npm_versionone npm package version
pypi_packagea PyPI distribution file
nuget_packagea NuGet package version
go_modulea Go module version
cargo_cratea Cargo crate version
rubygems_gema gem version
apk_packageone Alpine .apk package; restoring it regenerates the directory's APKINDEX.tar.gz
conda_packagea Conda package
conan_packageone stored file of a Conan recipe or package revision
terraform_modulea Terraform/OpenTofu module version
terraform_providerone platform zip of a Terraform/OpenTofu provider version
helm_charta Helm chart version
raw_artifacta raw file
docker_manifesta Docker/OCI manifest (and its tags)
docker_taga single Docker tag
rpm_packagean RPM package
pacmanan Arch Linux (pacman) package
deb_packagea Debian package

API ​

JSON is snake_case. An item:

json
{
  "id": "6f0c2b9e-...",
  "kind": "maven_artifact",
  "repository_id": "b1d1...",
  "repository": "releases",
  "format": "maven",
  "name": "com/acme/app/1.4.0/app-1.4.0.jar",
  "version": "1.4.0",
  "size_bytes": 1048576,
  "deleted_at": "2026-01-02T15:04:05Z",
  "deleted_by": "alice",
  "deleted_by_name": "alice",
  "expires_at": "2026-01-09T15:04:05Z",
  "purge_at": "2026-01-09T15:04:05Z"
}

expires_at is when the retention purge removes the item for good; purge_at is the same value under its original name, kept for existing clients. deleted_by_name is who deleted the item as a display name: the username, or the policy or peer that did it (cleanup-policy, federation:<peer>); it is empty when the deleter was not recorded and equals deleted_by.

A list is {"items":[...],"total":N,"page":P,"limit":L} (page from 1).

Method and pathWhoPurpose
GET /api/v1/trashadminAll trashed items, trashed repositories included. Query page, limit, kind, repository
GET /api/v1/repositories/{name}/trashdelete on the repositoryOne repository's trashed items. Query page, limit, kind
POST /api/v1/trash/{kind}/{id}/restoreadminRestore any item or a repository; 200 with the item
POST /api/v1/repositories/{name}/trash/{kind}/{id}/restoredelete on the repositoryRestore an item of that repository (not for kind repository)
DELETE /api/v1/trash/{kind}/{id}adminPurge now, permanently; 204
DELETE /api/v1/repositories/{name}/trashmanage on the repository (or admin)Empty the repository's trash: purge all its trashed items now. 200 {"purged": N, "remaining": M}
sh
HOST=http://localhost:8080

# what is in the trash (admin)
curl -sS -H "Authorization: Bearer <TOKEN>" "$HOST/api/v1/trash?kind=maven_artifact&limit=50"

# one repository's trash, as a user who can delete there
curl -sS -H "Authorization: Bearer <TOKEN>" "$HOST/api/v1/repositories/releases/trash"

# restore an item
curl -sS -X POST -H "Authorization: Bearer <TOKEN>" \
  "$HOST/api/v1/repositories/releases/trash/maven_artifact/<ID>/restore"

# restore a deleted repository (admin)
curl -sS -X POST -H "Authorization: Bearer <TOKEN>" "$HOST/api/v1/trash/repository/<ID>/restore"

# purge now
curl -sS -X DELETE -H "Authorization: Bearer <TOKEN>" "$HOST/api/v1/trash/docker_tag/<ID>"

Errors: 404 for an unknown or no longer trashed item, 409 for a restore conflict (trash_conflict, or repository_in_trash when the item's repository is itself in the trash; restore the repository first). Permissions: RBAC.

UI ​

A repository has a Trash tab (/repos/NAME?tab=trash) listing its deleted items with who deleted them (deleted_by_name), when, and when they will be purged (expires_at), with Restore for users who can delete there. Admins also get the instance-wide Settings > Trash (/settings/trash: items and deleted repositories) with Restore and Purge now. Deleting a repository asks for its name, the same as the API and CLI below.

CLI ​

sh
oa trash list                                    # everything (admin)
oa trash list --repo releases --kind maven_artifact
oa trash restore maven_artifact <id> --repo releases
oa trash restore repository <id>                 # admin
oa trash purge docker_tag <id> --yes             # admin; prompts without --yes
oa trash purge --repo releases --all --yes       # empty a repository's trash (repository manage permission)
oa repo delete old-repo --confirm old-repo

Details and flags: CLI.

Repositories ​

DELETE /api/v1/repository/{name} is the most destructive call of the API, so it needs the repository name as confirmation, as a query parameter or a JSON body:

sh
curl -X DELETE "$HOST/api/v1/repository/old-repo?confirm=old-repo" -H "Authorization: Bearer <TOKEN>"
curl -X DELETE "$HOST/api/v1/repository/old-repo" -H "Authorization: Bearer <TOKEN>" \
  -H 'Content-Type: application/json' -d '{"confirm":"old-repo"}'

A missing or wrong confirm is 400 confirmation_required; the oa CLI prompts for it on a terminal and takes --confirm <name> in scripts.

What happens next:

  • The repository is hidden at once: its native URLs, listings, search results, group memberships and API routes answer 404. Its packages are not touched individually; they stay as they are and are hidden along with the repository.
  • Its name stays reserved while it is in the trash. Creating a repository with the same name is 409, until the old one is purged or restored.
  • An admin can restore it from the trash (kind repository) within the retention period; everything comes back as it was, including its packages and settings. After the retention (or immediately when the repository's trash_retention_days is 0) the purge removes the repository, its packages and its quota counters.
  • Items that were trashed individually before the repository keep the retention window they had left at that moment: a restore shifts their deletion time by the time the repository spent in the trash.
  • A project cannot be deleted (409) while it owns repositories, repositories in the trash included; purge or restore them first.
  • The existing guard still applies: the delete is 409 while a release bundle or promotion references the repository.

Restore ​

Restoring returns the item with identical content and metadata, and the generated indexes are rebuilt to include it again.

A restore can conflict:

  • The path or version was published again meanwhile. Because uniqueness only applies to live rows, you may re-publish the same coordinates while the old copy is in the trash. Restoring the old copy then answers 409 trash_conflict. Delete or move the new one first, or purge the old one.
  • The repository is in the trash (409 repository_in_trash): restore the repository first.
  • A docker tag's manifest is in the trash (409 manifest_in_trash): restore the manifest first. Restoring a manifest also restores the tags that were trashed with it.

Docker blobs of trashed images. While an image is in the trash its manifest and tags are 404, and so are the layers and config blobs only trashed manifests reference (404 BLOB_UNKNOWN on GET/HEAD by digest); a layer a live image shares keeps being served, and restoring the manifest makes its blobs fetchable again. The blobs are deleted only once the manifest is purged and garbage collection runs. See Docker.

Purge ​

The purge worker runs every OPENARTIFACT_TRASH_PURGE_INTERVAL (default 15m) and permanently deletes items whose retention has expired, repositories included. An admin can purge any item immediately with DELETE /api/v1/trash/{kind}/{id} (UI: Purge now; CLI: oa trash purge).

Empty a repository's trash ​

DELETE /api/v1/repositories/{name}/trash purges every trashed item of one repository now (UI: Empty trash on the repository's Trash tab; CLI: oa trash purge --repo NAME --all). It takes the same steps as a single purge for each item: the record is removed, blobs are freed through the reference-counted path, the quota is released, an artifact.purged audit entry is written and the webhook event is emitted. It needs the repository-scoped manage permission (global admins have it everywhere), because it is destructive in bulk. Only items are purged: the repository itself, and a repository that is itself in the trash (404), are not touched. A request purges at most 1000 items and answers {"purged": N, "remaining": M}; repeat it while remaining is above 0 (the CLI does this itself).

Purging removes the record, releases the repository's quota (for npm it also prunes dist-tags and packages left with no versions), and frees blobs through the usual reference-counted path. Blobs are content-addressed and deduplicated: a blob is removed from storage only when no other row references it, including rows of other repositories and other formats, and including other items still in the trash. Purging one copy of a shared file therefore frees nothing.

Quota ​

Soft-deleted bytes keep counting against the repository's quota until purge: the delete does not release the reservation, and the quota reconcile counts trashed rows. The purge releases it. Quota errors say so: uploads refused over quota answer 507 with repository storage quota exceeded (bytes of trashed items count until purged; see the repository trash). If you delete to make room, either empty the repository's trash, purge the items or set the repository's trash_retention_days to 0. Restoring needs no new reservation.

Immutable releases ​

immutable_releases is a per-repository setting (POST /api/v1/repositories, PATCH /api/v1/repository/{name}, the repository's Settings tab, oa repo ... --immutable-releases), off by default. When on, a released version can be neither deleted nor overwritten: the request is 409, in the format's own error style. Admins can override one request with ?force=true; for anyone else the parameter is ignored. Pre-releases and snapshots stay mutable, so CI can keep publishing over them.

What counts as a release:

FormatA version is a release when
Maven, Ivy, sbtit is not a -SNAPSHOT version
npm, NuGet, Cargo, Helm, Terraformit is semver without a pre-release part (1.2.3 yes, 1.2.3-rc.1 no)
Gosemver without a pre-release part; pseudo-versions (v0.0.0-2026...-abcdef123456) count as pre-releases
PyPIPEP 440 without an a, b, rc or dev segment (1.0, 1.0.post1 are releases; 1.0rc1, 1.0.dev3 are not)
RubyGemsthe version has no letters (1.2.3 yes, 1.2.3.pre no)
Raw, Debian, RPM, Alpine, pacman, Conda, Conan, Dockerevery version is a release; there is no snapshot concept

The rule covers both directions:

  • Delete of a released version (including a Docker tag or manifest) is refused.
  • Overwrite of an existing release is refused where the format allows overwriting at all: Maven re-deploys, raw PUT over an existing path, Helm and others that replace content, and Docker re-tagging a tag to a different digest. Formats that already answer 409 for a duplicate keep doing so; pushing the same Docker digest again is not a change.
  • Cleanup policies skip released versions of such repositories.
sh
# refused
curl -X DELETE -H "Authorization: Bearer <TOKEN>" "$HOST/repos/maven/releases/com/acme/app/1.4.0/app-1.4.0.jar"   # 409

# admin override
curl -X DELETE -H "Authorization: Bearer <ADMIN_TOKEN>" "$HOST/repos/maven/releases/com/acme/app/1.4.0/app-1.4.0.jar?force=true"

A raw file uploaded through a presigned URL is guarded when the upload is completed, not when it is created: POST /api/v1/repositories/{name}/uploads/{id}/complete over an existing path answers 409 with code immutable_release and records nothing. The upload stays pending (the cleanup worker removes it after it expires), so an administrator can complete it with ?force=true; a plain retry is refused again.

Audit and webhook events ​

EventAuditWebhookWhen
artifact.deletedyes, details moved to trash or deletedyesany delete, soft or permanent
artifact.restoredyesyesan item is restored
artifact.purgedyesyesan item is purged, by an admin or by the worker
repository.deletedyesyesa repository is deleted (moved to the trash, or permanently with retention 0)
repository.restoredyesyesa repository is restored
repository.purgedyesyesa repository is purged

Payloads keep the existing shapes: the artifact events carry the artifact payload, the repository events the repository payload (webhooks). Subscribe to artifact.deleted and repository.deleted knowing they now also mean "moved to the trash". Audit entries are listed in monitoring.

Backups ​

Backups do not include trashed repositories or package rows (they are exported through the queries clients use, which hide the trash), so a restore brings back live data only; restore what you need from the trash before taking a backup. The per-repository trash_retention_days and immutable_releases settings are part of the repository records.

Free software under the AGPL-3.0-or-later.