diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts
index 08956c6..22f2cde 100644
--- a/admin/app/controllers/system_controller.ts
+++ b/admin/app/controllers/system_controller.ts
@@ -18,6 +18,7 @@ import {
preflightValidator,
serviceLogsValidator,
subscribeToReleaseNotesValidator,
+ uninstallServiceValidator,
updateCustomAppValidator,
updateServiceValidator,
setServiceAutoUpdateValidator,
@@ -467,6 +468,37 @@ export default class SystemController {
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
}
+ /** Uninstall a curated catalog app: stop + remove its container (optionally its image) and
+ * return the card to the available catalog. App data under the storage path stays on disk,
+ * so a later reinstall picks it back up. Custom apps are removed via deleteCustomApp instead,
+ * which also drops their DB record. */
+ async uninstallService({ request, response }: HttpContext) {
+ const payload = await request.validateUsing(uninstallServiceValidator)
+
+ const service = await Service.query().where('service_name', payload.service_name).first()
+ if (!service) {
+ return response.status(404).send({ error: `Service ${payload.service_name} not found` })
+ }
+ if (service.is_custom) {
+ return response.status(403).send({ error: 'Custom apps are removed via delete.' })
+ }
+ if (service.is_dependency_service) {
+ return response.status(403).send({ error: 'Dependency services cannot be uninstalled directly.' })
+ }
+ if (!service.installed) {
+ return response.status(409).send({ error: `Service ${payload.service_name} is not installed` })
+ }
+
+ const result = await this.dockerService.uninstallService(
+ payload.service_name,
+ payload.remove_image ?? false
+ )
+ if (!result.success) {
+ return response.status(500).send({ success: false, message: result.message })
+ }
+ return response.send({ success: true, message: result.message })
+ }
+
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
* metadata change — no container is touched. An empty/invalid value clears the override, after
* which the default host + port link is used again. */
diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts
index c836705..b951108 100644
--- a/admin/app/services/docker_service.ts
+++ b/admin/app/services/docker_service.ts
@@ -1867,6 +1867,34 @@ export class DockerService {
}
}
+ /**
+ * Uninstall a curated catalog app: stop and remove its container (and, optionally, its image),
+ * then mark the record not-installed so the card returns to the available catalog. Host
+ * bind-mount data is deliberately left on disk — a later reinstall picks it back up. Contrast
+ * with forceReinstall, which clears volumes and immediately recreates the container.
+ */
+ async uninstallService(
+ serviceName: string,
+ removeImage = false
+ ): Promise<{ success: boolean; message: string }> {
+ const service = await Service.query().where('service_name', serviceName).first()
+ if (!service || !service.installed) {
+ return { success: false, message: `Service ${serviceName} not found or not installed` }
+ }
+
+ // Container/image removal is shared with custom-app deletion — the lookup is by container
+ // name, which is the service_name for curated and custom apps alike.
+ const removal = await this.removeCustomAppContainer(serviceName, removeImage)
+ if (!removal.success) return removal
+
+ service.installed = false
+ service.installation_status = 'idle'
+ await service.save()
+ this.invalidateServicesStatusCache()
+
+ return { success: true, message: `Service ${serviceName} uninstalled` }
+ }
+
/** Find a container by its managed service name (`/serviceName`), or null. */
private async _findContainerByName(serviceName: string) {
const containers = await this.docker.listContainers({ all: true })
diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts
index 8f2d5a6..45a1144 100644
--- a/admin/app/validators/system.ts
+++ b/admin/app/validators/system.ts
@@ -131,6 +131,14 @@ export const deleteCustomAppValidator = vine.compile(
})
)
+export const uninstallServiceValidator = vine.compile(
+ vine.object({
+ service_name: vine.string().trim(),
+ // When true, also remove the backing Docker image (best-effort).
+ remove_image: vine.boolean().optional(),
+ })
+)
+
export const serviceLogsValidator = vine.compile(
vine.object({
tail: vine.number().min(1).max(2000).optional(),
diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts
index f1a8a7e..0b66758 100644
--- a/admin/inertia/lib/api.ts
+++ b/admin/inertia/lib/api.ts
@@ -1078,6 +1078,16 @@ class API {
})()
}
+ async uninstallService(service_name: string, remove_image = false) {
+ return catchInternal(async () => {
+ const response = await this.client.post<{ success: boolean; message: string }>(
+ '/system/services/uninstall',
+ { service_name, remove_image }
+ )
+ return response.data
+ })()
+ }
+
async updateCustomAppImage(service_name: string) {
return catchInternal(async () => {
const response = await this.client.post<{ success: boolean; message: string }>(
diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx
index 44c97f3..889f588 100644
--- a/admin/inertia/pages/supply-depot.tsx
+++ b/admin/inertia/pages/supply-depot.tsx
@@ -82,6 +82,7 @@ type Modal =
| { type: 'restart'; service: ServiceSlim }
| { type: 'reinstall'; service: ServiceSlim }
| { type: 'delete'; service: ServiceSlim }
+ | { type: 'uninstall'; service: ServiceSlim }
| { type: 'logs'; service: ServiceSlim }
| { type: 'stats'; service: ServiceSlim }
| { type: 'update'; service: ServiceSlim }
@@ -233,6 +234,16 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
else setTimeout(() => window.location.reload(), 1000)
}
+ async function handleUninstall(service: ServiceSlim) {
+ setModal(null)
+ setLoading(true)
+ const result = await api.uninstallService(service.service_name, removeImage)
+ setRemoveImage(false)
+ setLoading(false)
+ if (!result?.success) showError(result?.message || 'Failed to uninstall app.')
+ else setTimeout(() => window.location.reload(), 1000)
+ }
+
async function handleUpdate(service: ServiceSlim) {
setOpenDropdown(null)
setLoading(true)
@@ -450,6 +461,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onRestart={() => setModal({ type: 'restart', service })}
onReinstall={() => setModal({ type: 'reinstall', service })}
onDelete={() => setModal({ type: 'delete', service })}
+ onUninstall={() => setModal({ type: 'uninstall', service })}
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
@@ -484,6 +496,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
onRestart={() => setModal({ type: 'restart', service })}
onReinstall={() => setModal({ type: 'reinstall', service })}
onDelete={() => setModal({ type: 'delete', service })}
+ onUninstall={() => setModal({ type: 'uninstall', service })}
onLogs={() => setModal({ type: 'logs', service })}
onStats={() => setModal({ type: 'stats', service })}
onEdit={() => handleEdit(service)}
@@ -659,6 +672,38 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
)}
+ {/* Uninstall curated app modal */}
+ {modal?.type === 'uninstall' && (
+ {
+ setRemoveImage(false)
+ setModal(null)
+ }}
+ onConfirm={() => handleUninstall(modal.service)}
+ confirmText="Uninstall"
+ confirmIcon="IconTrash"
+ confirmVariant="danger"
+ confirmLoading={loading}
+ icon={}
+ >
+
+
This will remove the app from this device.
+
The container will be stopped and removed, and the app returns to the catalog below. App data under the storage folder stays on disk, so reinstalling brings it back as it was.