diff --git a/client/src/main/java/ctbrec/ui/CamrecApplication.java b/client/src/main/java/ctbrec/ui/CamrecApplication.java
index d8bf8e23..641c375f 100644
--- a/client/src/main/java/ctbrec/ui/CamrecApplication.java
+++ b/client/src/main/java/ctbrec/ui/CamrecApplication.java
@@ -12,6 +12,9 @@ import ctbrec.event.Event;
 import ctbrec.event.EventBusHolder;
 import ctbrec.event.EventHandler;
 import ctbrec.event.EventHandlerConfiguration;
+import ctbrec.image.LocalPortraitStore;
+import ctbrec.image.PortraitStore;
+import ctbrec.image.RemotePortraitStore;
 import ctbrec.io.BandwidthMeter;
 import ctbrec.io.ByteUnitFormatter;
 import ctbrec.io.HttpClient;
@@ -94,6 +97,7 @@ public class CamrecApplication extends Application {
     private final TabPane tabPane = new TabPane();
     private final List<Site> sites = new ArrayList<>();
     public static HttpClient httpClient;
+    public static PortraitStore portraitStore;
     public static String title;
     private Stage primaryStage;
     private RecordingsTab recordingsTab;
@@ -122,12 +126,21 @@ public class CamrecApplication extends Application {
         createRecorder();
         initSites();
         startOnlineMonitor();
+        createPortraitStore();
         createGui(primaryStage);
         checkForUpdates();
         registerClipboardListener();
         registerTrayIconListener();
     }
 
+    private void createPortraitStore() {
+        if (config.getSettings().localRecording) {
+            portraitStore = new LocalPortraitStore(config);
+        } else {
+            portraitStore = new RemotePortraitStore(httpClient, config);
+        }
+    }
+
     private void registerTrayIconListener() {
         EventBusHolder.BUS.register(new Object() {
             @Subscribe
diff --git a/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java b/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java
index 0ba64ba5..f9aeef04 100644
--- a/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java
+++ b/client/src/main/java/ctbrec/ui/action/AbstractPortraitAction.java
@@ -1,23 +1,19 @@
 package ctbrec.ui.action;
 
-import java.awt.Graphics2D;
-import java.awt.image.BufferedImage;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-
-import javax.imageio.ImageIO;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 import ctbrec.Config;
 import ctbrec.Model;
 import ctbrec.event.EventBusHolder;
+import ctbrec.ui.CamrecApplication;
 import javafx.scene.Node;
 
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+
 public abstract class AbstractPortraitAction {
-    private static final Logger LOG = LoggerFactory.getLogger(AbstractPortraitAction.class);
     public static final String FORMAT = "jpg";
 
     protected Node source;
@@ -32,11 +28,11 @@ public abstract class AbstractPortraitAction {
         return bimage;
     }
 
-    protected boolean copyToCacheAsJpg(String portraitId, BufferedImage portrait) throws IOException {
-        File output = getPortraitFile(portraitId);
-        Files.createDirectories(output.getParentFile().toPath());
-        LOG.debug("Writing scaled portrait to {}", output);
-        return ImageIO.write(portrait, FORMAT, output);
+    protected boolean store(String modelUrl, BufferedImage portrait) throws IOException {
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        ImageIO.write(portrait, FORMAT, bytes);
+        CamrecApplication.portraitStore.writePortrait(modelUrl, bytes.toByteArray());
+        return true;
     }
 
     protected File getPortraitFile(String portraitId) {
@@ -63,7 +59,7 @@ public abstract class AbstractPortraitAction {
 
     protected BufferedImage cropTopAndBottom(BufferedImage img) {
         int overlap = img.getHeight() - img.getWidth();
-        return img.getSubimage(0, overlap/2, img.getWidth(), img.getWidth());
+        return img.getSubimage(0, overlap / 2, img.getWidth(), img.getWidth());
     }
 
     protected void firePortraitChanged() {
@@ -71,7 +67,7 @@ public abstract class AbstractPortraitAction {
     }
 
     public static class PortraitChangedEvent {
-        private Model mdl;
+        private final Model mdl;
 
         public PortraitChangedEvent(Model model) {
             this.mdl = model;
diff --git a/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java
index 8747ea46..1bccd140 100644
--- a/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java
+++ b/client/src/main/java/ctbrec/ui/action/SetPortraitAction.java
@@ -1,22 +1,8 @@
 package ctbrec.ui.action;
 
-import java.awt.image.BufferedImage;
-import java.io.File;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.util.Arrays;
-import java.util.UUID;
-import java.util.function.Consumer;
-
-import javax.imageio.ImageIO;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import ctbrec.Config;
 import ctbrec.Model;
 import ctbrec.StringUtil;
+import ctbrec.ui.CamrecApplication;
 import ctbrec.ui.controls.Dialogs;
 import ctbrec.ui.controls.FileSelectionBox;
 import javafx.geometry.Insets;
@@ -24,11 +10,20 @@ import javafx.scene.Cursor;
 import javafx.scene.Node;
 import javafx.scene.control.Label;
 import javafx.scene.layout.GridPane;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.function.Consumer;
 
 public class SetPortraitAction extends AbstractPortraitAction {
     private static final Logger LOG = LoggerFactory.getLogger(SetPortraitAction.class);
 
-    private Consumer<Model> callback;
+    private final Consumer<Model> callback;
 
     public SetPortraitAction(Node source, Model selectedModel, Consumer<Model> callback) {
         this.source = source;
@@ -38,9 +33,6 @@ public class SetPortraitAction extends AbstractPortraitAction {
 
     public void execute() {
         source.setCursor(Cursor.WAIT);
-        String portraitId = Config.getInstance().getSettings().modelPortraits.getOrDefault(model.getUrl(),
-                UUID.nameUUIDFromBytes(model.getUrl().getBytes(StandardCharsets.UTF_8)).toString());
-
         GridPane pane = new GridPane();
         Label l = new Label("Select a portrait image. Leave empty to remove a portrait again.");
         pane.add(l, 0, 0);
@@ -56,17 +48,15 @@ public class SetPortraitAction extends AbstractPortraitAction {
         String selectedFile = portraitSelectionBox.fileProperty().getValue();
 
         if (StringUtil.isBlank(selectedFile)) {
-            removePortrait(portraitId);
+            removePortrait(model.getUrl());
         } else {
             LOG.debug("User selected {}", selectedFile);
-            boolean success = processImageFile(portraitId, selectedFile);
+            boolean success = processImageFile(selectedFile);
             if (success) {
-                Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId);
                 try {
-                    Config.getInstance().save();
                     firePortraitChanged();
                     runCallback();
-                } catch (IOException e) {
+                } catch (Exception e) {
                     Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e);
                 }
             }
@@ -74,14 +64,10 @@ public class SetPortraitAction extends AbstractPortraitAction {
         source.setCursor(Cursor.DEFAULT);
     }
 
-    private void removePortrait(String portraitId) {
-        File portraitFile = getPortraitFile(portraitId);
+    private void removePortrait(String modelUrl) {
         try {
-            if (portraitFile.exists()) {
-                Files.delete(portraitFile.toPath());
-            }
-            Config.getInstance().getSettings().modelPortraits.remove(model.getUrl());
-            Config.getInstance().save();
+            CamrecApplication.portraitStore.removePortrait(modelUrl);
+            firePortraitChanged();
             runCallback();
         } catch (IOException e) {
             Dialogs.showError("Remove Portrait", "Couldn't remove portrait image: ", e);
@@ -98,12 +84,12 @@ public class SetPortraitAction extends AbstractPortraitAction {
         }
     }
 
-    private boolean processImageFile(String portraitId, String selectedFile) {
+    private boolean processImageFile(String selectedFile) {
         try {
             BufferedImage original = ImageIO.read(new File(selectedFile));
             BufferedImage croppedImage = cropImage(original);
             BufferedImage portrait = convertToScaledJpg(croppedImage);
-            boolean success = copyToCacheAsJpg(portraitId, portrait);
+            boolean success = store(model.getUrl(), portrait);
             if (!success) {
                 LOG.debug("Available formats: {}", Arrays.toString(ImageIO.getWriterFormatNames()));
                 throw new IOException("No suitable writer found for image format " + FORMAT);
diff --git a/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java b/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java
index 7b23686c..308f00f0 100644
--- a/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java
+++ b/client/src/main/java/ctbrec/ui/action/SetThumbAsPortraitAction.java
@@ -1,25 +1,23 @@
 package ctbrec.ui.action;
 
-import java.awt.image.BufferedImage;
-import java.nio.charset.StandardCharsets;
-import java.util.UUID;
-
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import ctbrec.Config;
+import ctbrec.GlobalThreadPool;
 import ctbrec.Model;
 import ctbrec.ui.controls.Dialogs;
+import javafx.application.Platform;
 import javafx.embed.swing.SwingFXUtils;
 import javafx.scene.Cursor;
 import javafx.scene.Node;
 import javafx.scene.image.Image;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.awt.image.BufferedImage;
 
 public class SetThumbAsPortraitAction extends AbstractPortraitAction {
 
     private static final Logger LOG = LoggerFactory.getLogger(SetThumbAsPortraitAction.class);
 
-    private Image image;
+    private final Image image;
 
     public SetThumbAsPortraitAction(Node source, Model model, Image image) {
         this.source = source;
@@ -29,20 +27,20 @@ public class SetThumbAsPortraitAction extends AbstractPortraitAction {
 
     public void execute() {
         source.setCursor(Cursor.WAIT);
-        try {
-            BufferedImage bufferedImage = convertFxImageToAwt(image);
-            BufferedImage croppedImage = cropImage(bufferedImage);
-            BufferedImage portrait = convertToScaledJpg(croppedImage);
-            String portraitId = UUID.nameUUIDFromBytes(model.getUrl().getBytes(StandardCharsets.UTF_8)).toString();
-            copyToCacheAsJpg(portraitId, portrait);
-            Config.getInstance().getSettings().modelPortraits.put(model.getUrl(), portraitId);
-            Config.getInstance().save();
-            firePortraitChanged();
-        } catch (Exception e) {
-            LOG.error("Error while changing portrait image", e);
-            Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e);
-        }
-        source.setCursor(Cursor.DEFAULT);
+        GlobalThreadPool.submit(() -> {
+            try {
+                BufferedImage bufferedImage = convertFxImageToAwt(image);
+                BufferedImage croppedImage = cropImage(bufferedImage);
+                BufferedImage portrait = convertToScaledJpg(croppedImage);
+                store(model.getUrl(), portrait);
+                firePortraitChanged();
+            } catch (Exception e) {
+                LOG.error("Error while changing portrait image", e);
+                Platform.runLater(() -> Dialogs.showError("Set Portrait", "Couldn't change portrait image: ", e));
+            } finally {
+                Platform.runLater(() -> source.setCursor(Cursor.DEFAULT));
+            }
+        });
     }
 
     private BufferedImage convertFxImageToAwt(Image img) {
diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java b/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java
index a33b3b87..968c48c3 100644
--- a/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java
+++ b/client/src/main/java/ctbrec/ui/tabs/recorded/AbstractRecordedModelsTab.java
@@ -4,11 +4,17 @@ import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.eventbus.Subscribe;
-import ctbrec.*;
+import ctbrec.Config;
+import ctbrec.GlobalThreadPool;
+import ctbrec.Model;
+import ctbrec.StringUtil;
 import ctbrec.event.EventBusHolder;
+import ctbrec.image.PortraitStore;
+import ctbrec.io.HttpException;
 import ctbrec.recorder.Recorder;
 import ctbrec.sites.Site;
 import ctbrec.ui.AutosizeAlert;
+import ctbrec.ui.CamrecApplication;
 import ctbrec.ui.JavaFxModel;
 import ctbrec.ui.PreviewPopupHandler;
 import ctbrec.ui.action.AbstractPortraitAction.PortraitChangedEvent;
@@ -48,9 +54,9 @@ import javafx.scene.layout.HBox;
 import javafx.scene.layout.Priority;
 import javafx.stage.FileChooser;
 import javafx.util.Callback;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import lombok.extern.slf4j.Slf4j;
 
+import java.io.ByteArrayInputStream;
 import java.io.File;
 import java.io.IOException;
 import java.time.Instant;
@@ -60,8 +66,8 @@ import java.util.Objects;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.locks.ReentrantLock;
 
+@Slf4j
 public abstract class AbstractRecordedModelsTab extends Tab implements TabSelectionListener {
-    private static final Logger LOG = LoggerFactory.getLogger(AbstractRecordedModelsTab.class);
     private static final Image SILHOUETTE = new Image(AbstractRecordedModelsTab.class.getResourceAsStream("/silhouette_256.png"));
     protected static final String STYLE_ALIGN_CENTER = "-fx-alignment: CENTER;";
 
@@ -100,7 +106,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
     AbstractRecordedModelsTab(String text, String stateStorePrefix) {
         super(text);
         config = Config.getInstance();
-        portraitStore = new PortraitStore(config);
+        portraitStore = CamrecApplication.portraitStore;
         tableStateStore = new SettingTableViewStateStore(config, stateStorePrefix);
         table = new StatePersistingTableView<>(tableStateStore);
         registerPortraitListener();
@@ -112,6 +118,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
 
     @Subscribe
     public void portraitChanged(PortraitChangedEvent e) {
+        log.debug("Invalidate cache for {}", e.getModel());
         portraitCache.invalidate(e.getModel());
         if (table != null) {
             table.refresh();
@@ -230,7 +237,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
             } catch (IOException e) {
                 String msg = "An error occurred while exporting the model list";
                 Dialogs.showError(getTabPane().getScene(), "Export models", msg, e);
-                LOG.error(msg, e);
+                log.error(msg, e);
             }
         }
     }
@@ -244,7 +251,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
                     try {
                         recorder.addModel(model);
                     } catch (Exception e) {
-                        LOG.error("Couldn't add model to recording list", e);
+                        log.error("Couldn't add model to recording list", e);
                     }
                 }
                 return null;
@@ -269,7 +276,7 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
             } catch (IOException e) {
                 String msg = "An error occurred while importing the model list";
                 Dialogs.showError(getTabPane().getScene(), "Import models", msg, e);
-                LOG.error(msg, e);
+                log.error(msg, e);
             }
         }
     }
@@ -370,14 +377,14 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
         ContextMenu menu = new CustomMouseBehaviorContextMenu();
 
         ModelMenuContributor.newContributor(getTabPane(), Config.getInstance(), recorder) //
-        .withStartStopCallback(m -> Platform.runLater(this::reload)) //
-        .removeModelAfterIgnore(true) //
-        .withPortraitCallback(m -> Platform.runLater(() -> {
-            portraitCache.invalidate(m);
-            table.refresh();
-        }))
-        .afterwards(() -> Platform.runLater(this::reload))
-        .contributeToMenu(selectedModels, menu);
+                .withStartStopCallback(m -> Platform.runLater(this::reload)) //
+                .removeModelAfterIgnore(true) //
+                //                .withPortraitCallback(m -> Platform.runLater(() -> {
+                //                    portraitCache.invalidate(m);
+                //                    table.refresh();
+                //                }))
+                .afterwards(() -> Platform.runLater(this::reload))
+                .contributeToMenu(selectedModels, menu);
 
         return menu;
     }
@@ -408,8 +415,8 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
                     new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload));
                 } else {
                     new StartRecordingAction(modelInputField, List.of(newModel), recorder)
-                    .execute()
-                    .whenComplete((r, ex) -> Platform.runLater(this::reload));
+                            .execute()
+                            .whenComplete((r, ex) -> Platform.runLater(this::reload));
                 }
                 return;
             }
@@ -437,8 +444,8 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
                     new MarkForLaterRecordingAction(modelInputField, List.of(newModel), true, recorder).execute(m -> Platform.runLater(this::reload));
                 } else {
                     new StartRecordingAction(modelInputField, List.of(newModel), recorder)
-                    .execute()
-                    .whenComplete((r, ex) -> Platform.runLater(this::reload));
+                            .execute()
+                            .whenComplete((r, ex) -> Platform.runLater(this::reload));
                 }
                 return;
             }
@@ -558,6 +565,18 @@ public abstract class AbstractRecordedModelsTab extends Tab implements TabSelect
     }
 
     protected Image loadModelPortrait(Model model) {
-        return portraitStore.loadModelPortrait(model.getUrl()).orElse(SILHOUETTE);
+        try {
+            return portraitStore
+                    .loadModelPortraitByModelUrl(model.getUrl())
+                    .map(bytes -> new Image(new ByteArrayInputStream(bytes)))
+                    .orElse(SILHOUETTE);
+        } catch (HttpException e) {
+            if (e.getResponseCode() != 404) {
+                log.debug("Could not load portrait from server", e);
+            }
+        } catch (IOException e) {
+            log.debug("Could not load portrait from server", e);
+        }
+        return SILHOUETTE;
     }
 }
diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java
index c2c867c0..790c82a1 100644
--- a/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java
+++ b/client/src/main/java/ctbrec/ui/tabs/recorded/ModelImportExport.java
@@ -5,10 +5,10 @@ import com.squareup.moshi.JsonReader.Token;
 import ctbrec.Config;
 import ctbrec.Model;
 import ctbrec.ModelGroup;
-import ctbrec.io.FileJsonAdapter;
-import ctbrec.io.LocalTimeJsonAdapter;
-import ctbrec.io.ModelJsonAdapter;
-import ctbrec.io.UuidJSonAdapter;
+import ctbrec.image.LocalPortraitStore;
+import ctbrec.image.PortraitStore;
+import ctbrec.image.RemotePortraitStore;
+import ctbrec.io.*;
 import ctbrec.sites.Site;
 import okio.Buffer;
 import okio.Okio;
@@ -66,14 +66,25 @@ public class ModelImportExport {
             }
             if (exportOptions.includes().contains(ExportIncludes.PORTRAITS)) {
                 var portraits = config.getSettings().modelPortraits;
-                var portraitLoader = new PortraitStore(config);
+                PortraitStore portraitLoader;
+                if (config.getSettings().localRecording) {
+                    portraitLoader = new LocalPortraitStore(config);
+                } else {
+                    var httpClient = new HttpClient("camrec", config) {
+                        @Override
+                        public boolean login() {
+                            return false;
+                        }
+                    };
+                    portraitLoader = new RemotePortraitStore(httpClient, config);
+                }
                 if (portraits != null && !portraits.isEmpty()) {
                     writer.name("portraits");
                     writer.beginArray();
                     for (Map.Entry<String, String> entry : config.getSettings().modelPortraits.entrySet()) {
                         String modelUrl = entry.getKey();
                         String portraitId = entry.getValue();
-                        Optional<byte[]> portrait = portraitLoader.loadModelPortraitFile(modelUrl);
+                        Optional<byte[]> portrait = portraitLoader.loadModelPortraitByModelUrl(modelUrl);
                         if (portrait.isPresent()) {
                             writer.beginObject();
                             writer.name("url").value(modelUrl);
@@ -122,7 +133,18 @@ public class ModelImportExport {
     }
 
     private static void importPortraits(JsonReader reader, Config config) throws IOException {
-        PortraitStore portraitStore = new PortraitStore(config);
+        PortraitStore portraitStore;
+        if (config.getSettings().localRecording) {
+            portraitStore = new LocalPortraitStore(config);
+        } else {
+            var httpClient = new HttpClient("camrec", config) {
+                @Override
+                public boolean login() {
+                    return false;
+                }
+            };
+            portraitStore = new RemotePortraitStore(httpClient, config);
+        }
         reader.beginArray();
         while (reader.hasNext()) {
             reader.beginObject();
diff --git a/client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java b/client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java
deleted file mode 100644
index 596f94af..00000000
--- a/client/src/main/java/ctbrec/ui/tabs/recorded/PortraitStore.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package ctbrec.ui.tabs.recorded;
-
-import ctbrec.Config;
-import ctbrec.StringUtil;
-import javafx.scene.image.Image;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import java.io.ByteArrayInputStream;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.util.Optional;
-
-import static ctbrec.ui.action.AbstractPortraitAction.FORMAT;
-
-public record PortraitStore(Config config) {
-
-    private static final Logger LOG = LoggerFactory.getLogger(PortraitStore.class);
-
-    public Optional<Image> loadModelPortrait(String modelUrl) {
-        return loadModelPortraitFile(modelUrl).map(bytes -> new Image(new ByteArrayInputStream(bytes)));
-    }
-
-    public Optional<byte[]> loadModelPortraitFile(String modelUrl) {
-        String portraitId = config.getSettings().modelPortraits.get(modelUrl);
-        if (StringUtil.isNotBlank(portraitId)) {
-            File configDir = config.getConfigDir();
-            File portraitDir = new File(configDir, "portraits");
-            File portraitFile = new File(portraitDir, portraitId + '.' + FORMAT);
-            try {
-                return Optional.of(Files.readAllBytes(portraitFile.toPath()));
-            } catch (IOException e) {
-                LOG.error("Couldn't load portrait file {}", portraitFile, e);
-            }
-        }
-        return Optional.empty();
-    }
-
-    public void writePortrait(String id, byte[] data) throws IOException {
-        File configDir = config.getConfigDir();
-        File portraitDir = new File(configDir, "portraits");
-        File portraitFile = new File(portraitDir, id + '.' + FORMAT);
-        Files.createDirectories(portraitDir.toPath());
-        Files.write(portraitFile.toPath(), data);
-    }
-}
diff --git a/common/src/main/java/ctbrec/image/LocalPortraitStore.java b/common/src/main/java/ctbrec/image/LocalPortraitStore.java
new file mode 100644
index 00000000..456c7838
--- /dev/null
+++ b/common/src/main/java/ctbrec/image/LocalPortraitStore.java
@@ -0,0 +1,63 @@
+package ctbrec.image;
+
+import ctbrec.Config;
+import ctbrec.StringUtil;
+import lombok.extern.slf4j.Slf4j;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.util.Optional;
+import java.util.UUID;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+@Slf4j
+public record LocalPortraitStore(Config config) implements PortraitStore {
+
+    @Override
+    public Optional<byte[]> loadModelPortraitByModelUrl(String modelUrl) {
+        String portraitId = config.getSettings().modelPortraits.get(modelUrl);
+        return loadModelPortraitById(portraitId);
+    }
+
+    @Override
+    public Optional<byte[]> loadModelPortraitById(String portraitId) {
+        if (StringUtil.isNotBlank(portraitId)) {
+            File configDir = config.getConfigDir();
+            File portraitDir = new File(configDir, "portraits");
+            File portraitFile = new File(portraitDir, portraitId + '.' + FORMAT);
+            try {
+                return Optional.of(Files.readAllBytes(portraitFile.toPath()));
+            } catch (IOException e) {
+                log.error("Couldn't load portrait file {}", portraitFile, e);
+            }
+        }
+        return Optional.empty();
+    }
+
+    @Override
+    public void writePortrait(String modelUrl, byte[] data) throws IOException {
+        String portraitId = config.getSettings().modelPortraits.getOrDefault(modelUrl, UUID.nameUUIDFromBytes(modelUrl.getBytes(UTF_8)).toString());
+        File portraitFile = getPortraitFile(portraitId);
+        Files.write(portraitFile.toPath(), data);
+        config.getSettings().modelPortraits.put(modelUrl, portraitId);
+        config.save();
+    }
+
+    @Override
+    public void removePortrait(String modelUrl) throws IOException {
+        String portraitId = config.getSettings().modelPortraits.get(modelUrl);
+        File portraitFile = getPortraitFile(portraitId);
+        Files.delete(portraitFile.toPath());
+        config.getSettings().modelPortraits.remove(modelUrl);
+        config.save();
+    }
+
+    private File getPortraitFile(String portraitId) throws IOException {
+        File configDir = config.getConfigDir();
+        File portraitDir = new File(configDir, "portraits");
+        Files.createDirectories(portraitDir.toPath());
+        return new File(portraitDir, portraitId + '.' + FORMAT);
+    }
+}
diff --git a/common/src/main/java/ctbrec/image/PortraitStore.java b/common/src/main/java/ctbrec/image/PortraitStore.java
new file mode 100644
index 00000000..4666fb23
--- /dev/null
+++ b/common/src/main/java/ctbrec/image/PortraitStore.java
@@ -0,0 +1,18 @@
+package ctbrec.image;
+
+import java.io.IOException;
+import java.util.Optional;
+
+public interface PortraitStore {
+
+    String FORMAT = "jpg";
+
+    Optional<byte[]> loadModelPortraitById(String id) throws IOException;
+
+    Optional<byte[]> loadModelPortraitByModelUrl(String modelUrl) throws IOException;
+
+    //void writePortrait(String id, byte[] data) throws IOException;
+    void writePortrait(String modelUrl, byte[] data) throws IOException;
+
+    void removePortrait(String modelUrl) throws IOException;
+}
diff --git a/common/src/main/java/ctbrec/image/RemotePortraitStore.java b/common/src/main/java/ctbrec/image/RemotePortraitStore.java
new file mode 100644
index 00000000..9db28ae7
--- /dev/null
+++ b/common/src/main/java/ctbrec/image/RemotePortraitStore.java
@@ -0,0 +1,84 @@
+package ctbrec.image;
+
+import ctbrec.Config;
+import ctbrec.io.HttpClient;
+import ctbrec.io.HttpException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.MediaType;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.util.Optional;
+
+import static ctbrec.io.HttpConstants.MIMETYPE_IMAGE_JPG;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+@Slf4j
+@RequiredArgsConstructor
+public class RemotePortraitStore implements PortraitStore {
+
+    private final HttpClient httpClient;
+    private final Config config;
+
+    private String getEndpoint() {
+        return config.getServerUrl() + "/image/portrait";
+    }
+
+    private String getModelUrlEndpoint() {
+        return getEndpoint() + "/url/";
+    }
+
+    @Override
+    public Optional<byte[]> loadModelPortraitById(String id) throws IOException {
+        return load(getEndpoint() + '/' + id);
+    }
+
+    @Override
+    public Optional<byte[]> loadModelPortraitByModelUrl(String modelUrl) throws IOException {
+        return load(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8));
+    }
+
+    private Optional<byte[]> load(String url) throws IOException {
+        Request req = new Request.Builder()
+                .url(url)
+                .build();
+        try (Response resp = httpClient.execute(req)) {
+            if (resp.isSuccessful()) {
+                return Optional.of(resp.body().bytes());
+            } else {
+                throw new HttpException(resp.code(), resp.message());
+            }
+        }
+    }
+
+    @Override
+    public void writePortrait(String modelUrl, byte[] data) throws IOException {
+        RequestBody body = RequestBody.create(data, MediaType.parse(MIMETYPE_IMAGE_JPG));
+        Request req = new Request.Builder()
+                .url(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8))
+                .post(body)
+                .build();
+        try (Response resp = httpClient.execute(req)) {
+            if (!resp.isSuccessful()) {
+                throw new HttpException(resp.code(), resp.message());
+            }
+        }
+    }
+
+    @Override
+    public void removePortrait(String modelUrl) throws IOException {
+        Request req = new Request.Builder()
+                .url(getModelUrlEndpoint() + URLEncoder.encode(modelUrl, UTF_8))
+                .delete()
+                .build();
+        try (Response resp = httpClient.execute(req)) {
+            if (!resp.isSuccessful()) {
+                throw new HttpException(resp.code(), resp.message());
+            }
+        }
+    }
+}
diff --git a/common/src/main/java/ctbrec/io/HttpConstants.java b/common/src/main/java/ctbrec/io/HttpConstants.java
index 10c27868..aa71d868 100644
--- a/common/src/main/java/ctbrec/io/HttpConstants.java
+++ b/common/src/main/java/ctbrec/io/HttpConstants.java
@@ -16,6 +16,7 @@ public class HttpConstants {
     public static final String FORM_ENCODED = "application/x-www-form-urlencoded; charset=UTF-8";
     public static final String KEEP_ALIVE = "keep-alive";
     public static final String MIMETYPE_APPLICATION_JSON = "application/json";
+    public static final String MIMETYPE_IMAGE_JPG = "image/jpeg";
     public static final String MIMETYPE_TEXT_HTML = "text/html";
     public static final String NO_CACHE = "no-cache";
     public static final String ORIGIN = "Origin";
diff --git a/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java b/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java
index 100fe6b2..13db9f6b 100644
--- a/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java
+++ b/server/src/main/java/ctbrec/recorder/server/AbstractCtbrecServlet.java
@@ -1,21 +1,20 @@
 package ctbrec.recorder.server;
 
-import static javax.servlet.http.HttpServletResponse.*;
-
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
+import ctbrec.Config;
+import ctbrec.Hmac;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import ctbrec.Config;
-import ctbrec.Hmac;
+import static javax.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
 
 public abstract class AbstractCtbrecServlet extends HttpServlet {
 
@@ -23,17 +22,17 @@ public abstract class AbstractCtbrecServlet extends HttpServlet {
 
     boolean checkAuthentication(HttpServletRequest req, String body) throws IOException, InvalidKeyException, NoSuchAlgorithmException {
         boolean authenticated = false;
-        if(Config.getInstance().getSettings().key != null) {
+        if (Config.getInstance().getSettings().key != null) {
             String reqParamHmac = req.getParameter("hmac");
             String httpHeaderHmac = req.getHeader("CTBREC-HMAC");
             String hmac = null;
             String url = req.getRequestURI();
             url = url.substring(getServletContext().getContextPath().length());
 
-            if(reqParamHmac != null) {
+            if (reqParamHmac != null) {
                 hmac = reqParamHmac;
             }
-            if(httpHeaderHmac != null) {
+            if (httpHeaderHmac != null) {
                 hmac = httpHeaderHmac;
             }
 
@@ -50,13 +49,19 @@ public abstract class AbstractCtbrecServlet extends HttpServlet {
     String body(HttpServletRequest req) throws IOException {
         StringBuilder body = new StringBuilder();
         BufferedReader br = req.getReader();
-        String line= null;
-        while( (line = br.readLine()) != null ) {
+        String line = null;
+        while ((line = br.readLine()) != null) {
             body.append(line).append("\n");
         }
         return body.toString().trim();
     }
 
+    byte[] bodyAsByteArray(HttpServletRequest req) throws IOException {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream(req.getContentLength());
+        req.getInputStream().transferTo(bos);
+        return bos.toByteArray();
+    }
+
     void sendResponse(HttpServletResponse resp, int httpStatus, String message) {
         try {
             resp.setStatus(httpStatus);
diff --git a/server/src/main/java/ctbrec/recorder/server/HttpServer.java b/server/src/main/java/ctbrec/recorder/server/HttpServer.java
index df325a4d..80e566d5 100644
--- a/server/src/main/java/ctbrec/recorder/server/HttpServer.java
+++ b/server/src/main/java/ctbrec/recorder/server/HttpServer.java
@@ -7,6 +7,7 @@ import ctbrec.Version;
 import ctbrec.event.EventBusHolder;
 import ctbrec.event.EventHandler;
 import ctbrec.event.EventHandlerConfiguration;
+import ctbrec.image.LocalPortraitStore;
 import ctbrec.recorder.NextGenLocalRecorder;
 import ctbrec.recorder.OnlineMonitor;
 import ctbrec.recorder.Recorder;
@@ -59,6 +60,7 @@ import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
 
 public class HttpServer {
 
+    private static final int MiB = 1024 * 1024; // NOSONAR
     private static final Logger LOG = LoggerFactory.getLogger(HttpServer.class);
     private final Recorder recorder;
     private final OnlineMonitor onlineMonitor;
@@ -188,7 +190,7 @@ public class HttpServer {
         sslContextFactory.setTrustStorePassword(keyStorePassword);
 
         try (ServerConnector http = new ServerConnector(server, httpConnectionFactory);
-                ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) {
+             ServerConnector https = new ServerConnector(server, sslContextFactory, httpConnectionFactory)) {
 
             // connector for http
             http.setPort(this.config.getSettings().httpPort);
@@ -219,6 +221,22 @@ public class HttpServer {
             holder = new ServletHolder(hlsServlet);
             defaultContext.addServlet(holder, "/hls/*");
 
+            LocalPortraitStore portraitStore = new LocalPortraitStore(config);
+            ImageServlet imageServlet = new ImageServlet(portraitStore, config);
+            holder = new ServletHolder(imageServlet);
+            String location;
+            try {
+                location = File.createTempFile("upload", "").getParentFile().toString();
+            } catch (IOException e) {
+                location = ".";
+            }
+            long maxFileSize = 10L * MiB;
+            long maxRequestSize = 10L * MiB;
+            int fileSizeThreshold = MiB;
+            MultipartConfigElement multipartConfig = new MultipartConfigElement(location, maxFileSize, maxRequestSize, fileSizeThreshold);
+            holder.getRegistration().setMultipartConfig(multipartConfig);
+            defaultContext.addServlet(holder, ImageServlet.BASE_URL + "/*");
+
             if (this.config.getSettings().webinterface) {
                 startWebInterface(defaultContext, basicAuthContext);
             }
@@ -227,9 +245,9 @@ public class HttpServer {
             HandlerList handlers = new HandlerList();
             if (this.config.getSettings().transportLayerSecurity) {
                 server.addConnector(https);
-                handlers.setHandlers(new Handler[] { new SecuredRedirectHandler(), basicAuthContext, defaultContext });
+                handlers.setHandlers(new Handler[]{new SecuredRedirectHandler(), basicAuthContext, defaultContext});
             } else {
-                handlers.setHandlers(new Handler[] { basicAuthContext, defaultContext });
+                handlers.setHandlers(new Handler[]{basicAuthContext, defaultContext});
             }
             server.setHandler(handlers);
 
@@ -255,7 +273,7 @@ public class HttpServer {
         ServletHolder holder = new ServletHolder(staticFileServlet);
         String staticFileContext = "/static/*";
         defaultContext.addServlet(holder, staticFileContext);
-        LOG.info("Register static file servlet under {}", defaultContext.getContextPath()+staticFileContext);
+        LOG.info("Register static file servlet under {}", defaultContext.getContextPath() + staticFileContext);
 
         // servlet to retrieve the HMAC (secured by basic auth if a hmac key is set in the config)
         String username = this.config.getSettings().webinterfaceUsername;
@@ -298,7 +316,7 @@ public class HttpServer {
             @Override
             protected void handleErrorPage(HttpServletRequest request, Writer writer, int code, String message) throws IOException {
                 if (code == 404) {
-                    writer.write("<html><head><title>404</title><style>* {font-family: sans-serif}</style></head><body><h1>404</h1><p>Looking for <a href=\""+contextPath+"/static/index.html\">CTB Recorder</a>?</p></body>");
+                    writer.write("<html><head><title>404</title><style>* {font-family: sans-serif}</style></head><body><h1>404</h1><p>Looking for <a href=\"" + contextPath + "/static/index.html\">CTB Recorder</a>?</p></body>");
                 } else {
                     super.handleErrorPage(request, writer, code, message);
                 }
@@ -315,7 +333,7 @@ public class HttpServer {
 
             @Override
             public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
-                ((HttpServletResponse)response).addHeader("Server", "CTB Recorder/" + getVersion());
+                ((HttpServletResponse) response).addHeader("Server", "CTB Recorder/" + getVersion());
                 chain.doFilter(request, response);
             }
 
@@ -330,14 +348,14 @@ public class HttpServer {
     private static SecurityHandler basicAuth(String username, String password) {
         String realm = "CTB Recorder";
         UserStore userStore = new UserStore();
-        userStore.addUser(username, Credential.getCredential(password), new String[] { "user" });
+        userStore.addUser(username, Credential.getCredential(password), new String[]{"user"});
         HashLoginService l = new HashLoginService();
         l.setUserStore(userStore);
         l.setName(realm);
 
         Constraint constraint = new Constraint();
         constraint.setName(Constraint.__BASIC_AUTH);
-        constraint.setRoles(new String[] { "user" });
+        constraint.setRoles(new String[]{"user"});
         constraint.setAuthenticate(true);
 
         ConstraintMapping cm = new ConstraintMapping();
diff --git a/server/src/main/java/ctbrec/recorder/server/ImageServlet.java b/server/src/main/java/ctbrec/recorder/server/ImageServlet.java
new file mode 100644
index 00000000..efb0792c
--- /dev/null
+++ b/server/src/main/java/ctbrec/recorder/server/ImageServlet.java
@@ -0,0 +1,113 @@
+package ctbrec.recorder.server;
+
+import ctbrec.Config;
+import ctbrec.image.PortraitStore;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URLDecoder;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static ctbrec.io.HttpConstants.MIMETYPE_IMAGE_JPG;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static javax.servlet.http.HttpServletResponse.*;
+
+@Slf4j
+@RequiredArgsConstructor
+public class ImageServlet extends AbstractCtbrecServlet {
+
+    public static final String BASE_URL = "/image";
+    public static final String INTERNAL_SERVER_ERROR = "Internal Server Error";
+    private static final Pattern URL_PATTERN_PORTRAIT_BY_ID = Pattern.compile(BASE_URL + "/portrait/([0-9a-fA-F]{8}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{4}\\b-[0-9a-fA-F]{12})");
+    private static final Pattern URL_PATTERN_PORTRAIT_BY_URL = Pattern.compile(BASE_URL + "/portrait/url/(.*)");
+    private final PortraitStore portraitStore;
+    private final Config config;
+
+    @Override
+    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
+        String requestURI = req.getRequestURI();
+        try {
+            boolean authenticated = checkAuthentication(req, body(req));
+            if (!authenticated) {
+                sendResponse(resp, SC_UNAUTHORIZED, "HMAC does not match");
+                return;
+            }
+
+            Matcher m;
+            if ((m = URL_PATTERN_PORTRAIT_BY_ID.matcher(requestURI)).matches()) {
+                String portraitId = m.group(1);
+                servePortrait(resp, portraitId);
+            } else if ((m = URL_PATTERN_PORTRAIT_BY_URL.matcher(requestURI)).matches()) {
+                String modelUrl = URLDecoder.decode(m.group(1), UTF_8);
+                String portraitId = config.getSettings().modelPortraits.get(modelUrl);
+                servePortrait(resp, portraitId);
+            }
+        } catch (Exception e) {
+            log.error(INTERNAL_SERVER_ERROR, e);
+            sendResponse(resp, SC_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    private void servePortrait(HttpServletResponse resp, String portraitId) throws IOException {
+        log.debug("serving portrait {}", portraitId);
+        Optional<byte[]> imageData = portraitStore.loadModelPortraitById(portraitId);
+        if (imageData.isPresent()) {
+            resp.setStatus(SC_OK);
+            resp.setContentType(MIMETYPE_IMAGE_JPG);
+            byte[] b = imageData.get();
+            resp.setContentLength(b.length);
+            resp.getOutputStream().write(b, 0, b.length);
+            resp.getOutputStream().flush();
+        } else {
+            sendResponse(resp, SC_NOT_FOUND, "Portrait with ID " + portraitId + " not found");
+        }
+    }
+
+    @Override
+    protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
+        String requestURI = req.getRequestURI();
+        try {
+            //            boolean authenticated = checkAuthentication(req, body(req));
+            //            if (!authenticated) {
+            //                sendResponse(resp, SC_UNAUTHORIZED, "HMAC does not match");
+            //                return;
+            //            }
+
+            Matcher m;
+            if ((m = URL_PATTERN_PORTRAIT_BY_URL.matcher(requestURI)).matches()) {
+                String modelUrl = URLDecoder.decode(m.group(1), UTF_8);
+                byte[] data = bodyAsByteArray(req);
+                portraitStore.writePortrait(modelUrl, data);
+            }
+        } catch (Exception e) {
+            log.error(INTERNAL_SERVER_ERROR, e);
+            sendResponse(resp, SC_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
+        }
+    }
+
+    @Override
+    protected void doDelete(HttpServletRequest req, HttpServletResponse resp) {
+        String requestURI = req.getRequestURI();
+        try {
+            //            boolean authenticated = checkAuthentication(req, body(req));
+            //            if (!authenticated) {
+            //                sendResponse(resp, SC_UNAUTHORIZED, "HMAC does not match");
+            //                return;
+            //            }
+
+            Matcher m;
+            if ((m = URL_PATTERN_PORTRAIT_BY_URL.matcher(requestURI)).matches()) {
+                String modelUrl = URLDecoder.decode(m.group(1), UTF_8);
+                portraitStore.removePortrait(modelUrl);
+            }
+        } catch (Exception e) {
+            log.error(INTERNAL_SERVER_ERROR, e);
+            sendResponse(resp, SC_INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR);
+        }
+    }
+}