diff --git a/.env.example b/.env.example
index cd56239e..1de8bede 100644
--- a/.env.example
+++ b/.env.example
@@ -101,7 +101,7 @@ FEEDS_DOMAIN=feeds.dev.pico.sh:3004
 FEEDS_PROTOCOL=http
 FEEDS_DEBUG=1
 
-PGS_CADDYFILE=./caddy/Caddyfile
+PGS_CADDYFILE=./caddy/Caddyfile.pgs
 PGS_V4=
 PGS_V6=
 PGS_HTTP_V4=$PGS_V4:80
@@ -118,6 +118,8 @@ PGS_DOMAIN=pgs.dev.pico.sh:3005
 PGS_PROTOCOL=http
 PGS_STORAGE_DIR=.storage
 PGS_DEBUG=1
+PGS_CACHE_USER=testuser
+PGS_CACHE_PASSWORD=password
 
 PICO_CADDYFILE=./caddy/Caddyfile.pico
 PICO_V4=
diff --git a/caddy/Caddyfile.pgs b/caddy/Caddyfile.pgs
index cb251a3c..8f198dd4 100644
--- a/caddy/Caddyfile.pgs
+++ b/caddy/Caddyfile.pgs
@@ -7,14 +7,34 @@
 	servers {
 		metrics
 	}
+	cache {
+		ttl 300s
+		max_cacheable_body_bytes 1000000
+		otter
+		api {
+			souin
+		}
+	}
 }
 
 *.{$APP_DOMAIN}, {$APP_DOMAIN} {
-	reverse_proxy web:3000
 	tls {$APP_EMAIL} {
 		dns cloudflare {$CF_API_TOKEN}
 		resolvers 1.1.1.1
 	}
+	route {
+		@souinApi path /souin-api/*
+		basic_auth @souinApi {
+			testuser $2a$14$i1G0lil5qti7qahb4.Kte.wP/3O8uaStduzhBBtuDUZhMJeSjxbqm
+		}
+		cache {
+			regex {
+				exclude /check
+			}
+		}
+		reverse_proxy web:3000
+	}
+
 	encode zstd gzip
 
 	header {
diff --git a/caddy/Dockerfile b/caddy/Dockerfile
index 58fb629d..3922a4eb 100644
--- a/caddy/Dockerfile
+++ b/caddy/Dockerfile
@@ -8,7 +8,9 @@ ARG TARGETARCH
 ENV GOOS=${TARGETOS} GOARCH=${TARGETARCH}
 
 RUN xcaddy build \
-    --with github.com/caddy-dns/cloudflare
+    --with github.com/caddy-dns/cloudflare \
+    --with github.com/darkweak/souin/plugins/caddy@v1.7.2 \
+    --with github.com/darkweak/storages/otter/caddy
 
 FROM caddy:alpine
 
diff --git a/filehandlers/assets/handler.go b/filehandlers/assets/handler.go
index f57c829e..28c720c4 100644
--- a/filehandlers/assets/handler.go
+++ b/filehandlers/assets/handler.go
@@ -6,11 +6,13 @@ import (
 	"io"
 	"io/fs"
 	"log/slog"
+	"net/http"
 	"os"
 	"path"
 	"path/filepath"
 	"slices"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/charmbracelet/ssh"
@@ -98,16 +100,21 @@ type FileData struct {
 }
 
 type UploadAssetHandler struct {
-	DBPool  db.DB
-	Cfg     *shared.ConfigSite
-	Storage storage.StorageServe
+	DBPool             db.DB
+	Cfg                *shared.ConfigSite
+	Storage            storage.StorageServe
+	CacheClearingQueue chan string
 }
 
 func NewUploadAssetHandler(dbpool db.DB, cfg *shared.ConfigSite, storage storage.StorageServe) *UploadAssetHandler {
+	// Enable buffering so we don't slow down uploads.
+	ch := make(chan string, 100)
+	go runCacheQueue(ch, cfg)
 	return &UploadAssetHandler{
-		DBPool:  dbpool,
-		Cfg:     cfg,
-		Storage: storage,
+		DBPool:             dbpool,
+		Cfg:                cfg,
+		Storage:            storage,
+		CacheClearingQueue: ch,
 	}
 }
 
@@ -402,6 +409,7 @@ func (h *UploadAssetHandler) Write(s ssh.Session, entry *sendutils.FileEntry) (s
 		utils.BytesToGB(maxSize),
 		(float32(nextStorageSize)/float32(maxSize))*100,
 	)
+	h.CacheClearingQueue <- fmt.Sprintf("%s-%s", user.Name, projectName)
 
 	return str, nil
 }
@@ -468,8 +476,9 @@ func (h *UploadAssetHandler) Delete(s ssh.Session, entry *sendutils.FileEntry) e
 			return err
 		}
 	}
-
-	return h.Storage.DeleteObject(bucket, assetFilepath)
+	err = h.Storage.DeleteObject(bucket, assetFilepath)
+	h.CacheClearingQueue <- fmt.Sprintf("%s-%s", user.Name, projectName)
+	return err
 }
 
 func (h *UploadAssetHandler) validateAsset(data *FileData) (bool, error) {
@@ -515,3 +524,56 @@ func (h *UploadAssetHandler) writeAsset(reader io.Reader, data *FileData) (int64
 	)
 	return fsize, err
 }
+
+// runCacheQueue processes requests to purge the cache for a single site.
+// One message arrives per file that is written/deleted during uploads.
+// Repeated messages for the same site are grouped so that we only flush once
+// per site per 5 seconds.
+func runCacheQueue(ch chan string, cfg *shared.ConfigSite) {
+	cacheApiUrl := fmt.Sprintf("https://%s/souin-api/souin/", cfg.Domain)
+	var pendingFlushes sync.Map
+	tick := time.Tick(5 * time.Second)
+	for {
+		select {
+		case host := <-ch:
+			pendingFlushes.Store(host, host)
+		case <-tick:
+			go func() {
+				pendingFlushes.Range(func(key, value any) bool {
+					pendingFlushes.Delete(key)
+					err := purgeCache(key.(string), cacheApiUrl, cfg.CacheUser, cfg.CachePassword)
+					if err != nil {
+						cfg.Logger.Error("failed to clear cache", "err", err.Error())
+					}
+					return true
+				})
+			}()
+		}
+	}
+}
+
+// purgeCache send an HTTP request to the pgs Caddy instance which purges
+// cached entries for a given subdomain (like "fakeuser-www-proj"). We set a
+// "surrogate-key: <subdomain>" header on every pgs response which ensures all
+// cached assets for a given subdomain are grouped under a single key (which is
+// separate from the "GET-https-example.com-/path" key used for serving files
+// from the cache).
+func purgeCache(subdomain string, cacheApiUrl string, username string, password string) error {
+	client := &http.Client{
+		Timeout: time.Second * 5,
+	}
+	req, err := http.NewRequest("PURGE", cacheApiUrl, nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Add("Surrogate-Key", subdomain)
+	req.SetBasicAuth(username, password)
+	resp, err := client.Do(req)
+	if err != nil {
+		return err
+	}
+	if resp.StatusCode != 204 {
+		return fmt.Errorf("received unexpected response code %d", resp.StatusCode)
+	}
+	return nil
+}
diff --git a/pgs/api.go b/pgs/api.go
index bbbb3675..f0370289 100644
--- a/pgs/api.go
+++ b/pgs/api.go
@@ -354,6 +354,9 @@ func (h *AssetHandler) handle(logger *slog.Logger, w http.ResponseWriter, r *htt
 		w.Header().Set("content-type", contentType)
 	}
 
+	// Allows us to invalidate the cache when files are modified
+	w.Header().Set("surrogate-key", h.Subdomain)
+
 	finContentType := w.Header().Get("content-type")
 
 	// only track pages, not individual assets
diff --git a/pgs/config.go b/pgs/config.go
index 3cc9a57f..6b3c6085 100644
--- a/pgs/config.go
+++ b/pgs/config.go
@@ -16,6 +16,8 @@ func NewConfigSite() *shared.ConfigSite {
 	port := utils.GetEnv("PGS_WEB_PORT", "3000")
 	protocol := utils.GetEnv("PGS_PROTOCOL", "https")
 	storageDir := utils.GetEnv("PGS_STORAGE_DIR", ".storage")
+	pgsCacheUser := utils.GetEnv("PGS_CACHE_USER", "")
+	pgsCachePass := utils.GetEnv("PGS_CACHE_PASSWORD", "")
 	minioURL := utils.GetEnv("MINIO_URL", "")
 	minioUser := utils.GetEnv("MINIO_ROOT_USER", "")
 	minioPass := utils.GetEnv("MINIO_ROOT_PASSWORD", "")
@@ -32,6 +34,8 @@ func NewConfigSite() *shared.ConfigSite {
 		Protocol:           protocol,
 		DbURL:              dbURL,
 		StorageDir:         storageDir,
+		CacheUser:          pgsCacheUser,
+		CachePassword:      pgsCachePass,
 		MinioURL:           minioURL,
 		MinioUser:          minioUser,
 		MinioPass:          minioPass,
diff --git a/shared/config.go b/shared/config.go
index 0413e1e5..f566b7fb 100644
--- a/shared/config.go
+++ b/shared/config.go
@@ -37,6 +37,8 @@ type ConfigSite struct {
 	Protocol           string
 	DbURL              string
 	StorageDir         string
+	CacheUser          string
+	CachePassword      string
 	MinioURL           string
 	MinioUser          string
 	MinioPass          string