diff --git a/.gitignore b/.gitignore
index ae0de92..1ee5078 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
 target/
 
+# VS Code
+.vscode/
+
 # misc
 .DS_Store
diff --git a/src/main/java/io/apitally/common/ApitallyClient.java b/src/main/java/io/apitally/common/ApitallyClient.java
index 3a281db..ddc1737 100644
--- a/src/main/java/io/apitally/common/ApitallyClient.java
+++ b/src/main/java/io/apitally/common/ApitallyClient.java
@@ -11,7 +11,6 @@
 import java.util.Optional;
 import java.util.Queue;
 import java.util.Random;
-import java.util.UUID;
 import java.util.concurrent.CompletableFuture;
 import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.Executors;
@@ -60,7 +59,7 @@ public enum HubRequestStatus {
 
     private final String clientId;
     private final String env;
-    private final UUID instanceUuid;
+    private final InstanceLock instanceLock;
     private final HttpClient httpClient;
     private ScheduledExecutorService scheduler;
     private ScheduledFuture<?> syncTask;
@@ -80,7 +79,7 @@ public enum HubRequestStatus {
     public ApitallyClient(String clientId, String env, RequestLoggingConfig requestLoggingConfig) {
         this.clientId = clientId;
         this.env = env;
-        this.instanceUuid = java.util.UUID.randomUUID();
+        this.instanceLock = InstanceLock.create(clientId, env);
         this.httpClient = createHttpClient();
 
         this.requestCounter = new RequestCounter();
@@ -115,7 +114,7 @@ private URI getHubUrl(String endpoint, String query) {
     }
 
     public void setStartupData(List<Path> paths, Map<String, String> versions, String client) {
-        startupData = new StartupData(instanceUuid, paths, versions, client);
+        startupData = new StartupData(instanceLock.getInstanceUuid(), paths, versions, client);
     }
 
     private void sendStartupData() {
@@ -142,7 +141,7 @@ private void sendStartupData() {
 
     private void sendSyncData() {
         SyncData data = new SyncData(
-                instanceUuid,
+                instanceLock.getInstanceUuid(),
                 requestCounter.getAndResetRequests(),
                 validationErrorCounter.getAndResetValidationErrors(),
                 serverErrorCounter.getAndResetServerErrors(),
@@ -314,6 +313,7 @@ public void shutdown() {
                     scheduler.shutdownNow();
                 }
             }
+            instanceLock.close();
         } catch (InterruptedException e) {
             if (scheduler != null) {
                 scheduler.shutdownNow();
diff --git a/src/main/java/io/apitally/common/InstanceLock.java b/src/main/java/io/apitally/common/InstanceLock.java
new file mode 100644
index 0000000..b0d4b94
--- /dev/null
+++ b/src/main/java/io/apitally/common/InstanceLock.java
@@ -0,0 +1,135 @@
+package io.apitally.common;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileTime;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.HexFormat;
+import java.util.UUID;
+
+public class InstanceLock implements Closeable {
+    private static final int MAX_SLOTS = 100;
+    private static final int MAX_LOCK_AGE_SECONDS = 24 * 60 * 60;
+
+    private final UUID instanceUuid;
+    private final FileChannel lockChannel;
+
+    private InstanceLock(UUID uuid, FileChannel lockChannel) {
+        this.instanceUuid = uuid;
+        this.lockChannel = lockChannel;
+    }
+
+    public UUID getInstanceUuid() {
+        return instanceUuid;
+    }
+
+    public static InstanceLock create(String clientId, String env) {
+        return create(clientId, env, Path.of(System.getProperty("java.io.tmpdir"), "apitally"));
+    }
+
+    static InstanceLock create(String clientId, String env, Path lockDir) {
+        try {
+            Files.createDirectories(lockDir);
+        } catch (Exception e) {
+            return new InstanceLock(UUID.randomUUID(), null);
+        }
+
+        String appEnvHash;
+        try {
+            appEnvHash = getAppEnvHash(clientId, env);
+        } catch (Exception e) {
+            return new InstanceLock(UUID.randomUUID(), null);
+        }
+
+        for (int slot = 0; slot < MAX_SLOTS; slot++) {
+            Path lockPath = lockDir.resolve("instance_" + appEnvHash + "_" + slot + ".lock");
+            FileChannel channel = null;
+            try {
+                channel = FileChannel.open(
+                        lockPath,
+                        StandardOpenOption.CREATE,
+                        StandardOpenOption.READ,
+                        StandardOpenOption.WRITE);
+
+                FileLock lock = channel.tryLock();
+                if (lock == null) {
+                    channel.close();
+                    continue;
+                }
+
+                FileTime lastModified = Files.getLastModifiedTime(lockPath);
+                boolean tooOld = Duration.between(lastModified.toInstant(), Instant.now()).getSeconds() > MAX_LOCK_AGE_SECONDS;
+
+                String existingUuid = readChannel(channel);
+                UUID uuid = parseUuid(existingUuid);
+
+                if (uuid != null && !tooOld) {
+                    return new InstanceLock(uuid, channel);
+                }
+
+                UUID newUuid = UUID.randomUUID();
+                channel.truncate(0);
+                channel.write(ByteBuffer.wrap(newUuid.toString().getBytes(StandardCharsets.UTF_8)));
+                channel.force(true);
+
+                return new InstanceLock(newUuid, channel);
+            } catch (Exception e) {
+                if (channel != null) {
+                    try {
+                        channel.close();
+                    } catch (IOException ignored) {
+                    }
+                }
+            }
+        }
+
+        return new InstanceLock(UUID.randomUUID(), null);
+    }
+
+    private static String readChannel(FileChannel channel) throws IOException {
+        channel.position(0);
+        ByteBuffer buffer = ByteBuffer.allocate(64);
+        int bytesRead = channel.read(buffer);
+        if (bytesRead <= 0) {
+            return "";
+        }
+        return new String(buffer.array(), 0, bytesRead, StandardCharsets.UTF_8).trim();
+    }
+
+    private static UUID parseUuid(String s) {
+        if (s == null || s.isEmpty()) {
+            return null;
+        }
+        try {
+            return UUID.fromString(s);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+    }
+
+    private static String getAppEnvHash(String clientId, String env) throws NoSuchAlgorithmException {
+        MessageDigest digest = MessageDigest.getInstance("SHA-256");
+        byte[] hash = digest.digest((clientId + ":" + env).getBytes(StandardCharsets.UTF_8));
+        return HexFormat.of().formatHex(hash, 0, 4);
+    }
+
+    @Override
+    public void close() {
+        if (lockChannel != null) {
+            try {
+                lockChannel.close();
+            } catch (IOException ignored) {
+            }
+        }
+    }
+}
diff --git a/src/test/java/io/apitally/common/InstanceLockTest.java b/src/test/java/io/apitally/common/InstanceLockTest.java
new file mode 100644
index 0000000..a947759
--- /dev/null
+++ b/src/test/java/io/apitally/common/InstanceLockTest.java
@@ -0,0 +1,152 @@
+package io.apitally.common;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Comparator;
+import java.util.HexFormat;
+import java.util.UUID;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class InstanceLockTest {
+    private Path tempDir;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        tempDir = Files.createTempDirectory("apitally_test_");
+    }
+
+    @AfterEach
+    void tearDown() throws IOException {
+        if (tempDir != null && Files.exists(tempDir)) {
+            try (var paths = Files.walk(tempDir)) {
+                paths.sorted(Comparator.reverseOrder())
+                        .forEach(path -> {
+                            try {
+                                Files.deleteIfExists(path);
+                            } catch (IOException ignored) {
+                            }
+                        });
+            }
+        }
+    }
+
+    @Test
+    void createsNewUUID() throws IOException {
+        String clientId = UUID.randomUUID().toString();
+        String env = "test";
+
+        try (InstanceLock lock = InstanceLock.create(clientId, env, tempDir)) {
+            assertNotNull(lock.getInstanceUuid());
+
+            String hash = getAppEnvHash(clientId, env);
+            Path lockFile = tempDir.resolve("instance_" + hash + "_0.lock");
+            assertTrue(Files.exists(lockFile));
+        }
+    }
+
+    @Test
+    void reusesExistingUUID() throws IOException {
+        String clientId = UUID.randomUUID().toString();
+        String env = "test";
+
+        UUID firstUuid;
+        try (InstanceLock lock1 = InstanceLock.create(clientId, env, tempDir)) {
+            firstUuid = lock1.getInstanceUuid();
+        }
+
+        try (InstanceLock lock2 = InstanceLock.create(clientId, env, tempDir)) {
+            assertEquals(firstUuid, lock2.getInstanceUuid());
+        }
+    }
+
+    @Test
+    void differentEnvsGetDifferentUUIDs() throws IOException {
+        String clientId = UUID.randomUUID().toString();
+
+        try (InstanceLock lock1 = InstanceLock.create(clientId, "env1", tempDir);
+                InstanceLock lock2 = InstanceLock.create(clientId, "env2", tempDir)) {
+            assertNotEquals(lock1.getInstanceUuid(), lock2.getInstanceUuid());
+        }
+    }
+
+    @Test
+    void multipleSlots() throws IOException {
+        String clientId = UUID.randomUUID().toString();
+        String env = "test";
+
+        try (InstanceLock lock1 = InstanceLock.create(clientId, env, tempDir);
+                InstanceLock lock2 = InstanceLock.create(clientId, env, tempDir);
+                InstanceLock lock3 = InstanceLock.create(clientId, env, tempDir)) {
+
+            assertNotEquals(lock1.getInstanceUuid(), lock2.getInstanceUuid());
+            assertNotEquals(lock2.getInstanceUuid(), lock3.getInstanceUuid());
+            assertNotEquals(lock1.getInstanceUuid(), lock3.getInstanceUuid());
+
+            String hash = getAppEnvHash(clientId, env);
+            assertTrue(Files.exists(tempDir.resolve("instance_" + hash + "_0.lock")));
+            assertTrue(Files.exists(tempDir.resolve("instance_" + hash + "_1.lock")));
+            assertTrue(Files.exists(tempDir.resolve("instance_" + hash + "_2.lock")));
+        }
+    }
+
+    @Test
+    void overwritesOldUUID() throws IOException {
+        String clientId = UUID.randomUUID().toString();
+        String env = "test";
+        String hash = getAppEnvHash(clientId, env);
+
+        String oldUuid = "550e8400-e29b-41d4-a716-446655440000";
+        Path lockFile = tempDir.resolve("instance_" + hash + "_0.lock");
+        Files.writeString(lockFile, oldUuid);
+        Instant oldTime = Instant.now().minus(25, ChronoUnit.HOURS);
+        Files.setLastModifiedTime(lockFile, java.nio.file.attribute.FileTime.from(oldTime));
+
+        try (InstanceLock lock = InstanceLock.create(clientId, env, tempDir)) {
+            assertNotEquals(UUID.fromString(oldUuid), lock.getInstanceUuid());
+            assertNotNull(lock.getInstanceUuid());
+        }
+    }
+
+    @Test
+    void overwritesInvalidUUID() throws IOException {
+        String clientId = UUID.randomUUID().toString();
+        String env = "test";
+        String hash = getAppEnvHash(clientId, env);
+
+        Path lockFile = tempDir.resolve("instance_" + hash + "_0.lock");
+        Files.writeString(lockFile, "not-a-valid-uuid");
+
+        UUID uuid;
+        try (InstanceLock lock = InstanceLock.create(clientId, env, tempDir)) {
+            uuid = lock.getInstanceUuid();
+            assertNotNull(uuid);
+        }
+
+        String content = Files.readString(lockFile).trim();
+        assertEquals(uuid.toString(), content);
+    }
+
+    private static String getAppEnvHash(String clientId, String env) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] hash = digest.digest((clientId + ":" + env).getBytes(StandardCharsets.UTF_8));
+            return HexFormat.of().formatHex(hash, 0, 4);
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 not available", e);
+        }
+    }
+}
