Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ All notable changes to teploy are documented here. Format follows [Keep a Change

## [Unreleased]

### Fixed

- `teploy remove` now deletes the server-built images (`<app>-build-<version>`)
of the containers it removes. Before, they stayed forever: the deploy-time
72-hour image prune skips images a kept container still uses, and remove
took the containers away without the images. Images are taken from the
app's own containers (never a name pattern), removal is never forced, and
pulled images are left alone. `--json` reports them as `removed_images`.

## [0.1.46] - 2026-10-21

### Fixed
Expand Down
60 changes: 58 additions & 2 deletions internal/cli/remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"io"
"os"
"os/signal"
"slices"
"strings"

"github.com/spf13/cobra"
"github.com/useteploy/teploy/internal/build"
"github.com/useteploy/teploy/internal/caddy"
"github.com/useteploy/teploy/internal/confighome"
"github.com/useteploy/teploy/internal/docker"
Expand All @@ -28,8 +30,13 @@ func newRemoveCmd(flags *Flags) *cobra.Command {
Aliases: []string{"destroy"},
Short: "Retire an app: containers, proxy route, and deploy state",
Long: `Remove is the inverse of deploy. It stops and removes the app's containers,
removes its Caddy route (reloading the proxy), and deletes its deploy state
under /deployments/<app>.
removes its Caddy route (reloading the proxy), deletes the server-built images
those containers ran (<app>-build-<version>; pulled images are left alone),
and deletes its deploy state under /deployments/<app>.

The removal itself is recorded in the app's event history (who removed it,
and whether --purge was used). With --purge, that record is all that stays,
so the server history can still answer what happened to the app.

Data is preserved by default: volumes/, accessory data, and running accessory
containers are left in place so a removal is never silently destructive.
Expand Down Expand Up @@ -61,6 +68,7 @@ type removeSummary struct {
App string `json:"app"`
Server string `json:"server"`
RemovedContainers []string `json:"removed_containers"`
RemovedImages []string `json:"removed_images,omitempty"`
KeptAccessories []string `json:"kept_accessories,omitempty"`
RemovedAutomation []string `json:"removed_automation,omitempty"`
Route string `json:"route"` // "removed", "redirected", "none"
Expand Down Expand Up @@ -175,6 +183,10 @@ func executeRemove(ctx context.Context, exec ssh.Executor, app string, domains [
if err != nil {
return nil, fmt.Errorf("listing containers: %w", err)
}
// Containers created by image ID report the ID; resolve it to the tags
// so their build images are found too.
containers = dk.ResolveImageTags(ctx, containers)
var buildImages []string
for _, c := range containers {
if c.Labels["teploy.role"] == "accessory" && !opts.Purge {
summary.KeptAccessories = append(summary.KeptAccessories, c.Name)
Expand All @@ -192,6 +204,19 @@ func executeRemove(ctx context.Context, exec ssh.Executor, app string, domains [
return nil, err
}
summary.RemovedContainers = append(summary.RemovedContainers, c.Name)
tags := c.ImageTags
if len(tags) == 0 {
tags = []string{c.Image}
}
for _, tag := range tags {
if strings.HasPrefix(tag, build.ImageTag(app, "")) && !slices.Contains(buildImages, tag) {
buildImages = append(buildImages, tag)
}
}
}
summary.RemovedImages = removeBuildImages(ctx, exec, buildImages)
for _, img := range summary.RemovedImages {
fmt.Fprintf(out, "Removed image %s\n", img)
}

// 2. Route. Servers running only ingress:host apps have no Caddyfile —
Expand Down Expand Up @@ -360,3 +385,34 @@ func removeAppAutomation(ctx context.Context, exec ssh.Executor, app string, pur
}
return removed, nil
}

// removeBuildImages deletes the server-built images (<app>-build-<version>)
// of the containers remove just took away. Nothing can deploy or roll back
// to them once the app is gone, and the deploy-time 72h prune never reached
// them while a kept container still used them. The names come from the
// app's own containers, never a name pattern, so another app's images are
// never matched. Best-effort and never forced: an image some other
// container still uses stays, and a failure does not fail the remove.
func removeBuildImages(ctx context.Context, exec ssh.Executor, images []string) []string {
if len(images) == 0 {
return nil
}
quoted := make([]string, len(images))
for i, img := range images {
quoted[i] = ssh.ShellQuote(img)
}
out, _ := exec.Run(ctx, "docker rmi "+strings.Join(quoted, " ")+" 2>/dev/null || true")
var removed []string
for _, line := range strings.Split(out, "\n") {
tag, ok := strings.CutPrefix(strings.TrimSpace(line), "Untagged: ")
if !ok {
continue
}
for _, img := range images {
if tag == img || tag == img+":latest" {
removed = append(removed, img)
}
}
}
return removed
}
42 changes: 42 additions & 0 deletions internal/cli/remove_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,45 @@ func TestExecuteRemoveRefusesWhileDeployRuns(t *testing.T) {
}
}
}

func TestExecuteRemoveDeletesItsBuildImages(t *testing.T) {
cmds := append([]ssh.MockCommand{
{
Match: "docker ps --all --filter label=teploy.app=",
Output: `{"ID":"aaa111","Names":"scratch-web-aaa","Image":"scratch-build-aaa","State":"running","Status":"Up","Labels":"teploy.app=scratch,teploy.process=web"}
{"ID":"aaa112","Names":"scratch-worker-aaa","Image":"scratch-build-aaa","State":"running","Status":"Up","Labels":"teploy.app=scratch,teploy.process=worker"}
{"ID":"ccc333","Names":"scratch-web-ccc","Image":"scratch-build-ccc","State":"exited","Status":"Exited","Labels":"teploy.app=scratch,teploy.process=web"}
{"ID":"ddd444","Names":"scratch-web-ddd","Image":"nginx:alpine","State":"exited","Status":"Exited","Labels":"teploy.app=scratch,teploy.process=web"}
{"ID":"eee555","Names":"scratch-web-eee","Image":"be6a74814c55","State":"exited","Status":"Exited","Labels":"teploy.app=scratch,teploy.process=web"}`,
},
// A container created by image ID reports the ID; its tag is resolved.
{Match: "docker image inspect --format", Output: "sha256:be6a74814c55" + strings.Repeat("0", 52) + ` ["scratch-build-eee:latest"]`},
{Match: "docker stop -t 10", Output: ""},
{Match: "docker rm ", Output: ""},
// ccc's image is still used elsewhere: docker refuses it, remove carries on.
{Match: "docker rmi ", Output: "Untagged: scratch-build-aaa:latest\nUntagged: scratch-build-eee:latest\nDeleted: sha256:0123"},
{Match: "rm -rf '/deployments/scratch'", Output: ""},
}, caddyMocks()...)
exec := ssh.NewMockExecutor("test-host", append(cmds, removeAutomationMocks()...)...)

sum, err := executeRemove(context.Background(), exec, "scratch", nil, removeOptions{Purge: true}, io.Discard)
if err != nil {
t.Fatalf("executeRemove: %v", err)
}
var rmi string
for _, call := range exec.Calls {
if strings.HasPrefix(call, "docker rmi ") {
if rmi != "" {
t.Fatalf("docker rmi ran twice: %v", exec.Calls)
}
rmi = call
}
}
// Each build image once, never forced, never a pulled image.
if rmi != "docker rmi 'scratch-build-aaa' 'scratch-build-ccc' 'scratch-build-eee:latest' 2>/dev/null || true" {
t.Fatalf("rmi call = %q", rmi)
}
if fmt.Sprint(sum.RemovedImages) != "[scratch-build-aaa scratch-build-eee:latest]" {
t.Fatalf("removed images = %v", sum.RemovedImages)
}
}
1 change: 1 addition & 0 deletions internal/cli/trigger_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ func canonicalRepository(raw string) (string, error) {
}
return u.String(), nil
}

// sameRepositoryPath compares two canonical repositories by host and path.
// The forge API reports its HTTP clone URL, while a checkout may use SSH on
// a different port (self-hosted forges almost always do); the transport
Expand Down
Loading