diff --git a/client/src/main/java/org/mvndaemon/mvnd/client/DaemonClientConnection.java b/client/src/main/java/org/mvndaemon/mvnd/client/DaemonClientConnection.java
index b3e357dc5..30ceb9609 100644
--- a/client/src/main/java/org/mvndaemon/mvnd/client/DaemonClientConnection.java
+++ b/client/src/main/java/org/mvndaemon/mvnd/client/DaemonClientConnection.java
@@ -94,7 +94,7 @@ public void dispatch(Message message) throws DaemonException.ConnectException {
     }
 
     public List<Message> receive() throws ConnectException, StaleAddressException {
-        int maxKeepAliveMs = parameters.keepAliveMs() * parameters.maxLostKeepAlive();
+        long maxKeepAliveMs = parameters.keepAlive().toMillis() * parameters.maxLostKeepAlive();
         while (true) {
             try {
                 final Message m = queue.poll(maxKeepAliveMs, TimeUnit.MILLISECONDS);
diff --git a/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java b/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java
index 2dae6196f..e4a363911 100644
--- a/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java
+++ b/client/src/main/java/org/mvndaemon/mvnd/client/DaemonParameters.java
@@ -21,6 +21,7 @@
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -41,6 +42,7 @@
 import org.mvndaemon.mvnd.common.BuildProperties;
 import org.mvndaemon.mvnd.common.Environment;
 import org.mvndaemon.mvnd.common.Os;
+import org.mvndaemon.mvnd.common.TimeUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -49,6 +51,8 @@
  */
 public class DaemonParameters {
 
+    public static final String LOG_EXTENSION = ".log";
+
     private static final Logger LOG = LoggerFactory.getLogger(DaemonParameters.class);
     private static final String EXT_CLASS_PATH = "maven.ext.class.path";
     private static final String EXTENSIONS_FILENAME = ".mvn/extensions.xml";
@@ -182,11 +186,11 @@ public Path registry() {
     }
 
     public Path daemonLog(String daemon) {
-        return daemonStorage().resolve("daemon-" + daemon + ".log");
+        return daemonStorage().resolve("daemon-" + daemon + LOG_EXTENSION);
     }
 
     public Path daemonOutLog(String daemon) {
-        return daemonStorage().resolve("daemon-" + daemon + ".out.log");
+        return daemonStorage().resolve("daemon-" + daemon + ".out" + LOG_EXTENSION);
     }
 
     public Path multiModuleProjectDirectory() {
@@ -268,8 +272,8 @@ public DaemonParameters cd(Path newUserDir) {
                 .put(Environment.USER_DIR, newUserDir));
     }
 
-    public int keepAliveMs() {
-        return property(Environment.DAEMON_KEEP_ALIVE_MS).orFail().asInt();
+    public Duration keepAlive() {
+        return property(Environment.DAEMON_KEEP_ALIVE).orFail().asDuration();
     }
 
     public int maxLostKeepAlive() {
@@ -284,6 +288,10 @@ public int rollingWindowSize() {
         return property(Environment.MVND_ROLLING_WINDOW_SIZE).orFail().asInt();
     }
 
+    public Duration purgeLogPeriod() {
+        return property(Environment.MVND_LOG_PURGE_PERIOD).orFail().asDuration();
+    }
+
     public static String findDefaultMultimoduleProjectDirectory(Path pwd) {
         Path dir = pwd;
         do {
@@ -579,5 +587,9 @@ public int asInt(IntUnaryOperator function) {
             return function.applyAsInt(asInt());
         }
 
+        public Duration asDuration() {
+            return TimeUtils.toDuration(get());
+        }
+
     }
 }
diff --git a/client/src/main/java/org/mvndaemon/mvnd/client/DefaultClient.java b/client/src/main/java/org/mvndaemon/mvnd/client/DefaultClient.java
index 05f0b3738..7981f16ff 100644
--- a/client/src/main/java/org/mvndaemon/mvnd/client/DefaultClient.java
+++ b/client/src/main/java/org/mvndaemon/mvnd/client/DefaultClient.java
@@ -15,13 +15,22 @@
  */
 package org.mvndaemon.mvnd.client;
 
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileTime;
+import java.time.Duration;
 import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
 import org.fusesource.jansi.Ansi;
 import org.jline.utils.AttributedString;
 import org.jline.utils.AttributedStyle;
@@ -33,11 +42,14 @@
 import org.mvndaemon.mvnd.common.Message;
 import org.mvndaemon.mvnd.common.Message.BuildException;
 import org.mvndaemon.mvnd.common.OsUtils;
+import org.mvndaemon.mvnd.common.TimeUtils;
 import org.mvndaemon.mvnd.common.logging.ClientOutput;
 import org.mvndaemon.mvnd.common.logging.TerminalOutput;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import static org.mvndaemon.mvnd.client.DaemonParameters.LOG_EXTENSION;
+
 public class DefaultClient implements Client {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(DefaultClient.class);
@@ -183,6 +195,12 @@ public ExecutionResult execute(ClientOutput output, List<String> argv) {
                 }
                 return new DefaultResult(argv, null);
             }
+            boolean purge = args.remove("--purge");
+            if (purge) {
+                String result = purgeLogs();
+                output.accept(Message.display(result != null ? result : "Nothing to purge"));
+                return new DefaultResult(argv, null);
+            }
 
             if (args.stream().noneMatch(arg -> arg.startsWith("-T") || arg.equals("--threads"))) {
                 args.add("--threads");
@@ -216,27 +234,112 @@ public ExecutionResult execute(ClientOutput output, List<String> argv) {
 
                 output.accept(Message.buildStatus("Build request sent"));
 
-                while (true) {
-                    final List<Message> messages = daemon.receive();
-                    output.accept(messages);
-                    for (Message m : messages) {
-                        switch (m.getType()) {
-                        case Message.CANCEL_BUILD:
-                            return new DefaultResult(argv,
-                                    new InterruptedException("The build was canceled"));
-                        case Message.BUILD_EXCEPTION:
-                            final BuildException e = (BuildException) m;
-                            return new DefaultResult(argv,
-                                    new Exception(e.getClassName() + ": " + e.getMessage() + "\n" + e.getStackTrace()));
-                        case Message.BUILD_STOPPED:
-                            return new DefaultResult(argv, null);
+                // We've sent the request, so it gives us a bit of time to purge the logs
+                AtomicReference<String> purgeMessage = new AtomicReference<>();
+                Thread purgeLog = new Thread(() -> {
+                    purgeMessage.set(purgeLogs());
+                }, "Log purge");
+                purgeLog.setDaemon(true);
+                purgeLog.start();
+
+                try {
+                    while (true) {
+                        final List<Message> messages = daemon.receive();
+                        output.accept(messages);
+                        for (Message m : messages) {
+                            switch (m.getType()) {
+                            case Message.CANCEL_BUILD:
+                                return new DefaultResult(argv,
+                                        new InterruptedException("The build was canceled"));
+                            case Message.BUILD_EXCEPTION:
+                                final BuildException e = (BuildException) m;
+                                return new DefaultResult(argv,
+                                        new Exception(e.getClassName() + ": " + e.getMessage() + "\n" + e.getStackTrace()));
+                            case Message.BUILD_STOPPED:
+                                return new DefaultResult(argv, null);
+                            }
                         }
                     }
+                } finally {
+                    String msg = purgeMessage.get();
+                    if (msg != null) {
+                        output.accept(Message.display(msg));
+                    }
                 }
             }
         }
     }
 
+    private String purgeLogs() {
+        Path storage = parameters.daemonStorage();
+        Duration purgeLogPeriod = parameters.purgeLogPeriod();
+        if (!Files.isDirectory(storage) || !TimeUtils.isPositive(purgeLogPeriod)) {
+            return null;
+        }
+        String date = DateTimeFormatter.ofPattern("yyyy-MM-dd").withZone(ZoneId.systemDefault()).format(Instant.now());
+        Path log = storage.resolve("purge-" + date + ".log");
+        List<Path> deleted = new ArrayList<>();
+        List<Throwable> exceptions = new ArrayList<>();
+        FileTime limit = FileTime.from(Instant.now().minus(purgeLogPeriod));
+        try {
+            Files.list(storage)
+                    .filter(p -> p.getFileName().toString().endsWith(LOG_EXTENSION))
+                    .filter(p -> !log.equals(p))
+                    .filter(p -> {
+                        try {
+                            FileTime lmt = Files.getLastModifiedTime(p);
+                            return lmt.compareTo(limit) < 0;
+                        } catch (IOException e) {
+                            exceptions.add(e);
+                            return false;
+                        }
+                    })
+                    .forEach(p -> {
+                        try {
+                            Files.delete(p);
+                            deleted.add(p);
+                        } catch (IOException e) {
+                            exceptions.add(e);
+                        }
+                    });
+        } catch (Exception e) {
+            exceptions.add(e);
+        }
+        if (exceptions.isEmpty() && deleted.isEmpty()) {
+            return null;
+        }
+        String logMessage;
+        try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(log,
+                StandardOpenOption.WRITE, StandardOpenOption.APPEND, StandardOpenOption.CREATE))) {
+            w.printf("Purge executed at %s%n", Instant.now().toString());
+            if (deleted.isEmpty()) {
+                w.printf("No files deleted.%n");
+            } else {
+                w.printf("Deleted files:%n");
+                for (Path p : deleted) {
+                    w.printf("    %s%n", p.toString());
+                }
+            }
+            if (!exceptions.isEmpty()) {
+                w.printf("%d exception(s) occurred during the purge", exceptions.size());
+                for (Throwable t : exceptions) {
+                    t.printStackTrace(w);
+                }
+            }
+            char[] buf = new char[80];
+            Arrays.fill(buf, '=');
+            w.printf("%s%n", new String(buf));
+            logMessage = "log available in " + log.toString();
+        } catch (IOException e) {
+            logMessage = "an exception occurred when writing log to " + log.toString() + ": " + e.toString();
+        }
+        if (exceptions.isEmpty()) {
+            return String.format("Purged %d log files (%s)", deleted.size(), logMessage);
+        } else {
+            return String.format("Purged %d log files with %d exceptions (%s)", deleted.size(), exceptions.size(), logMessage);
+        }
+    }
+
     private static class DefaultResult implements ExecutionResult {
 
         private final Exception exception;
diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java b/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java
index c23d8efe0..e6eb6e3ff 100644
--- a/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java
+++ b/common/src/main/java/org/mvndaemon/mvnd/common/Environment.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2019 the original author or authors.
+ * Copyright 2020 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -17,14 +17,20 @@
 
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.time.Duration;
 import java.util.Collection;
 import java.util.Locale;
 import java.util.Objects;
 import java.util.Properties;
-import java.util.concurrent.TimeUnit;
 
 /**
  * Collects system properties and environment variables used by mvnd client or server.
+ *
+ * Duration properties such as {@link #DAEMON_IDLE_TIMEOUT}, {@link #DAEMON_KEEP_ALIVE},
+ * {@link #DAEMON_EXPIRATION_CHECK_DELAY} or {@link #MVND_LOG_PURGE_PERIOD} are expressed
+ * in a human readable format such as {@code 2h30m}, {@code 600ms} or {@code 10 seconds}.
+ * The available units are <i>d/day/days</i>, <i>h/hour/hours</i>, <i>m/min/minute/minutes</i>,
+ * <i>s/sec/second/seconds</i> and <i>ms/millis/msec/milliseconds</i>.
  */
 public enum Environment {
     //
@@ -68,14 +74,18 @@ public String asCommandLineProperty(String value) {
      * The number of log lines to display for each Maven module that is built in parallel.
      */
     MVND_ROLLING_WINDOW_SIZE("mvnd.rollingWindowSize", null, "0", false),
+    /**
+     * The automatic log purge period
+     */
+    MVND_LOG_PURGE_PERIOD("mvnd.logPurgePeriod", null, "1w", false),
     /**
      * The path to the daemon registry
      */
     DAEMON_REGISTRY("daemon.registry", null, null, false),
     MVND_NO_DAEMON("mvnd.noDaemon", "MVND_NO_DAEMON", "false", true),
     DAEMON_DEBUG("daemon.debug", null, false, true),
-    DAEMON_IDLE_TIMEOUT_MS("daemon.idleTimeoutMs", null, TimeUnit.HOURS.toMillis(3), true),
-    DAEMON_KEEP_ALIVE_MS("daemon.keepAliveMs", null, TimeUnit.SECONDS.toMillis(1), true),
+    DAEMON_IDLE_TIMEOUT("daemon.idleTimeout", null, "3 hours", true),
+    DAEMON_KEEP_ALIVE("daemon.keepAlive", null, "1 sec", true),
     DAEMON_MAX_LOST_KEEP_ALIVE("daemon.maxLostKeepAlive", null, 3, false),
     /**
      * The minimum number of threads to use when constructing the default {@code -T} parameter for the daemon.
@@ -102,7 +112,7 @@ public String asCommandLineProperty(String value) {
     /**
      * The maven builder name to use. Ignored if the user passes
      *
-     * @{@code -b} or @{@code --builder} on the command line
+     * {@code -b} or {@code --builder} on the command line
      */
     MVND_BUILDER("mvnd.builder", null, "smart", false) {
         @Override
@@ -146,17 +156,13 @@ public String asCommandLineProperty(String value) {
     /**
      * Interval to check if the daemon should expire
      */
-    DAEMON_EXPIRATION_CHECK_DELAY_MS("daemon.expirationCheckDelayMs", null, TimeUnit.SECONDS.toMillis(10), true),
+    DAEMON_EXPIRATION_CHECK_DELAY("daemon.expirationCheckDelay", null, "10 seconds", true),
     /**
      * Period after which idle daemons will shut down
      */
-    DAEMON_DUPLICATE_DAEMON_GRACE_PERIOD_MS("daemon.duplicateDaemonGracePeriodMs", null, TimeUnit.SECONDS.toMillis(10), true),
+    DAEMON_DUPLICATE_DAEMON_GRACE_PERIOD("daemon.duplicateDaemonGracePeriod", null, "10 seconds", true),
     ;
 
-    public static final int DEFAULT_IDLE_TIMEOUT = (int) TimeUnit.HOURS.toMillis(3);
-
-    public static final int DEFAULT_KEEP_ALIVE = (int) TimeUnit.SECONDS.toMillis(1);
-
     static Properties properties = System.getProperties();
 
     public static void setProperties(Properties properties) {
@@ -219,6 +225,10 @@ public Path asPath() {
         return Paths.get(result);
     }
 
+    public Duration asDuration() {
+        return TimeUtils.toDuration(asString());
+    }
+
     public String asCommandLineProperty(String value) {
         return "-D" + property + "=" + value;
     }
diff --git a/common/src/main/java/org/mvndaemon/mvnd/common/TimeUtils.java b/common/src/main/java/org/mvndaemon/mvnd/common/TimeUtils.java
new file mode 100644
index 000000000..ea07effb7
--- /dev/null
+++ b/common/src/main/java/org/mvndaemon/mvnd/common/TimeUtils.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright 2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mvndaemon.mvnd.common;
+
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Time utils.
+ */
+public final class TimeUtils {
+
+    private static final int ONE_UNIT = 1;
+    public static final long DAYS_MILLIS = TimeUnit.DAYS.toMillis(ONE_UNIT);
+    public static final long HOURS_MILLIS = TimeUnit.HOURS.toMillis(ONE_UNIT);
+    public static final long MINUTES_MILLIS = TimeUnit.MINUTES.toMillis(ONE_UNIT);
+    public static final long SECONDS_MILLIS = TimeUnit.SECONDS.toMillis(ONE_UNIT);
+
+    private static final Pattern DURATION_PATTERN = Pattern.compile(
+            "(?<n>-?\\d+)" +
+                    "|" +
+                    "(" +
+                        "((?<d>\\d+)\\s*d(ay(s)?)?)?" + "\\s*" +
+                        "((?<h>\\d+)\\s*h(our(s)?)?)?" + "\\s*" +
+                        "((?<m>\\d+)\\s*m(in(ute(s)?)?)?)?" + "\\s*" +
+                        "((?<s>\\d+(\\.\\d+)?)\\s*s(ec(ond(s)?)?)?)?" + "\\s*" +
+                        "((?<l>\\d+(\\.\\d+)?)\\s*m(illi)?s(ec(ond)?(s)?)?)?" +
+                    ")",
+            Pattern.CASE_INSENSITIVE);
+
+    private TimeUtils() {
+    }
+
+    public static boolean isPositive(Duration dur) {
+        return dur.getSeconds() > 0 || dur.getNano() != 0;
+    }
+
+    public static String printDuration(Duration uptime) {
+        return printDuration(uptime.toMillis());
+    }
+
+    /**
+     * This will print time in human readable format from milliseconds.
+     * Examples:
+     *    500 -> 500ms
+     *    1300 -> 1s300ms
+     *    310300 -> 5m10s300ms
+     *    6600000 -> 1h50m
+     *
+     * @param  millis time in milliseconds
+     * @return           time in string
+     */
+    public static String printDuration(long millis) {
+        if (millis < 0) {
+            return Long.toString(millis);
+        }
+        final StringBuilder sb = new StringBuilder();
+        if (millis >= DAYS_MILLIS) {
+            sb.append(millis / DAYS_MILLIS).append("d");
+            millis %= DAYS_MILLIS;
+        }
+        if (millis >= HOURS_MILLIS) {
+            sb.append(millis / HOURS_MILLIS).append("h");
+            millis %= HOURS_MILLIS;
+        }
+        if (millis >= MINUTES_MILLIS) {
+            sb.append(millis / MINUTES_MILLIS).append("m");
+            millis %= MINUTES_MILLIS;
+        }
+        if (millis >= SECONDS_MILLIS) {
+            sb.append(millis / SECONDS_MILLIS).append("s");
+            millis %= SECONDS_MILLIS;
+        }
+        if (millis >= ONE_UNIT || sb.length() == 0) {
+            sb.append(millis / ONE_UNIT).append("ms");
+        }
+        return sb.toString();
+    }
+
+    public static Duration toDuration(String source) throws IllegalArgumentException {
+        return Duration.ofMillis(toMilliSeconds(source));
+    }
+
+    public static long toMilliSeconds(String source) throws IllegalArgumentException {
+        Matcher matcher = DURATION_PATTERN.matcher(source);
+        if (!matcher.matches()) {
+            throw new IllegalArgumentException("Unable to parse duration: '" + source + "'");
+        }
+        String n = matcher.group("n");
+        if (n != null) {
+            return Long.parseLong(n);
+        } else {
+            String d = matcher.group("d");
+            String h = matcher.group("h");
+            String m = matcher.group("m");
+            String s = matcher.group("s");
+            String l = matcher.group("l");
+            return (d != null ? TimeUnit.DAYS.toMillis(Long.parseLong(d)) : 0)
+                + (h != null ? TimeUnit.HOURS.toMillis(Long.parseLong(h)) : 0)
+                + (m != null ? TimeUnit.MINUTES.toMillis(Long.parseLong(m)) : 0)
+                + (s != null ? TimeUnit.SECONDS.toMillis(Long.parseLong(s)) : 0)
+                + (l != null ? TimeUnit.MILLISECONDS.toMillis(Long.parseLong(l)) : 0);
+        }
+    }
+
+}
diff --git a/common/src/test/java/org/mvndaemon/mvnd/common/TimeUtilsTest.java b/common/src/test/java/org/mvndaemon/mvnd/common/TimeUtilsTest.java
new file mode 100644
index 000000000..12bfcc25a
--- /dev/null
+++ b/common/src/test/java/org/mvndaemon/mvnd/common/TimeUtilsTest.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.mvndaemon.mvnd.common;
+
+import java.time.Duration;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+public class TimeUtilsTest {
+
+    @Test
+    public void testToTimeAsString() {
+        assertEquals("600ms", TimeUtils.printDuration(TimeUtils.toDuration("600ms")));
+        assertEquals("-1", TimeUtils.printDuration(TimeUtils.toDuration("-1")));
+        assertEquals("0ms", TimeUtils.printDuration(TimeUtils.toDuration("0ms")));
+        assertEquals("1s", TimeUtils.printDuration(TimeUtils.toDuration("1000ms")));
+        assertEquals("1m600ms", TimeUtils.printDuration(TimeUtils.toDuration("1minute 600ms")));
+        assertEquals("1m1s100ms", TimeUtils.printDuration(TimeUtils.toDuration("1m1100ms")));
+        assertEquals("5m10s300ms", TimeUtils.printDuration(310300));
+        assertEquals("5s500ms", TimeUtils.printDuration(5500));
+        assertEquals("1h50m", TimeUtils.printDuration(6600000));
+        assertEquals("2d3h4m", TimeUtils.printDuration(Duration.parse("P2DT3H4M").toMillis()));
+        assertEquals("2d4m", TimeUtils.printDuration(Duration.parse("P2DT4M").toMillis()));
+    }
+
+}
diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/DaemonExpiration.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/DaemonExpiration.java
index bbbda8a2b..d91d59a05 100644
--- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/DaemonExpiration.java
+++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/DaemonExpiration.java
@@ -17,6 +17,8 @@
 
 import java.nio.file.Files;
 import java.nio.file.Paths;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
@@ -28,6 +30,7 @@
 import org.mvndaemon.mvnd.common.DaemonInfo;
 import org.mvndaemon.mvnd.common.DaemonState;
 import org.mvndaemon.mvnd.common.Environment;
+import org.mvndaemon.mvnd.common.TimeUtils;
 
 import static org.mvndaemon.mvnd.common.DaemonExpirationStatus.DO_NOT_EXPIRE;
 import static org.mvndaemon.mvnd.common.DaemonExpirationStatus.GRACEFUL_EXPIRE;
@@ -41,8 +44,6 @@
  */
 public class DaemonExpiration {
 
-    public static final int DUPLICATE_DAEMON_GRACE_PERIOD_MS = 10000;
-
     public interface DaemonExpirationStrategy {
 
         DaemonExpirationResult checkExpiration(Server daemon);
@@ -53,7 +54,7 @@ public static DaemonExpirationStrategy master() {
         return any(
                 any(gcTrashing(), lowHeapSpace(), lowNonHeap()),
                 all(compatible(), duplicateGracePeriod(), notMostRecentlyUsed()),
-                idleTimeout(Environment.DAEMON_IDLE_TIMEOUT_MS.asInt()),
+                idleTimeout(Environment.DAEMON_IDLE_TIMEOUT.asDuration()),
                 all(duplicateGracePeriod(), notMostRecentlyUsed(), lowMemory(0.05)),
                 registryUnavailable());
     }
@@ -82,26 +83,14 @@ static DaemonExpirationStrategy lowMemory(double minFreeMemoryPercentage) {
     }
 
     static DaemonExpirationStrategy duplicateGracePeriod() {
-        return idleTimeout(Environment.DAEMON_DUPLICATE_DAEMON_GRACE_PERIOD_MS.asInt());
+        return idleTimeout(Environment.DAEMON_DUPLICATE_DAEMON_GRACE_PERIOD.asDuration());
     }
 
-    private static final long HOUR = 60 * 60 * 1000;
-    private static final long MINUTE = 60 * 1000;
-    private static final long SECOND = 1000;
-
-    static DaemonExpirationStrategy idleTimeout(long timeout) {
+    static DaemonExpirationStrategy idleTimeout(Duration timeout) {
         return daemon -> {
-            long idl = System.currentTimeMillis() - daemon.getLastIdle();
-            if (daemon.getState() == DaemonState.Idle && idl > timeout) {
-                String str;
-                if (idl > HOUR) {
-                    str = (idl / HOUR) + " hours";
-                } else if (idl > MINUTE) {
-                    str = (idl / MINUTE) + " minutes";
-                } else {
-                    str = (idl / SECOND) + " seconds";
-                }
-                return new DaemonExpirationResult(QUIET_EXPIRE, "after being idle for " + str);
+            Duration idl = Duration.between(Instant.ofEpochMilli(daemon.getLastIdle()), Instant.now());
+            if (daemon.getState() == DaemonState.Idle && idl.compareTo(timeout) > 0) {
+                return new DaemonExpirationResult(QUIET_EXPIRE, "after being idle for " + TimeUtils.printDuration(idl));
             } else {
                 return NOT_TRIGGERED;
             }
diff --git a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java
index c3b1d506d..1703e19e0 100644
--- a/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java
+++ b/daemon/src/main/java/org/mvndaemon/mvnd/daemon/Server.java
@@ -21,6 +21,7 @@
 import java.net.InetSocketAddress;
 import java.nio.channels.ServerSocketChannel;
 import java.nio.channels.SocketChannel;
+import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Comparator;
@@ -165,9 +166,9 @@ public void clearCache(String clazzName, String fieldName) {
 
     public void run() {
         try {
-            int expirationCheckDelayMs = Environment.DAEMON_EXPIRATION_CHECK_DELAY_MS.asInt();
+            Duration expirationCheckDelay = Environment.DAEMON_EXPIRATION_CHECK_DELAY.asDuration();
             executor.scheduleAtFixedRate(this::expirationCheck,
-                    expirationCheckDelayMs, expirationCheckDelayMs, TimeUnit.MILLISECONDS);
+                    expirationCheckDelay.toMillis(), expirationCheckDelay.toMillis(), TimeUnit.MILLISECONDS);
             LOGGER.info("Daemon started");
             if (noDaemon) {
                 try (SocketChannel socket = this.socket.accept()) {
@@ -405,7 +406,7 @@ private void cancelNow() {
     private void handle(DaemonConnection connection, BuildRequest buildRequest) {
         updateState(Busy);
         try {
-            int keepAlive = Environment.DAEMON_KEEP_ALIVE_MS.asInt();
+            Duration keepAlive = Environment.DAEMON_KEEP_ALIVE.asDuration();
 
             LOGGER.info("Executing request");
 
@@ -421,7 +422,7 @@ private void handle(DaemonConnection connection, BuildRequest buildRequest) {
                     while (true) {
                         Message m;
                         if (flushed) {
-                            m = sendQueue.poll(keepAlive, TimeUnit.MILLISECONDS);
+                            m = sendQueue.poll(keepAlive.toMillis(), TimeUnit.MILLISECONDS);
                             if (m == null) {
                                 m = Message.KEEP_ALIVE_SINGLETON;
                             }
diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/MvndTestExtension.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/MvndTestExtension.java
index 7d44db20d..ed9134864 100644
--- a/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/MvndTestExtension.java
+++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/MvndTestExtension.java
@@ -35,6 +35,7 @@
 import org.mvndaemon.mvnd.client.DefaultClient;
 import org.mvndaemon.mvnd.common.DaemonRegistry;
 import org.mvndaemon.mvnd.common.Environment;
+import org.mvndaemon.mvnd.common.TimeUtils;
 
 import static org.mvndaemon.mvnd.junit.TestUtils.deleteDir;
 
@@ -205,8 +206,8 @@ public static MvndResource create(String className, String rawProjectDir, boolea
                     Paths.get(System.getProperty("java.home")).toAbsolutePath().normalize(),
                     localMavenRepository, settingsPath,
                     logback,
-                    Environment.DEFAULT_IDLE_TIMEOUT,
-                    Environment.DEFAULT_KEEP_ALIVE,
+                    TimeUtils.toDuration(Environment.DAEMON_IDLE_TIMEOUT.getDef()),
+                    TimeUtils.toDuration(Environment.DAEMON_KEEP_ALIVE.getDef()),
                     Integer.parseInt(Environment.DAEMON_MAX_LOST_KEEP_ALIVE.getDef()));
             final TestRegistry registry = new TestRegistry(parameters.registry());
 
diff --git a/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/TestParameters.java b/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/TestParameters.java
index a42247b5b..0f48c5357 100644
--- a/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/TestParameters.java
+++ b/integration-tests/src/test/java/org/mvndaemon/mvnd/junit/TestParameters.java
@@ -16,8 +16,10 @@
 package org.mvndaemon.mvnd.junit;
 
 import java.nio.file.Path;
+import java.time.Duration;
 import org.mvndaemon.mvnd.client.DaemonParameters;
 import org.mvndaemon.mvnd.common.Environment;
+import org.mvndaemon.mvnd.common.TimeUtils;
 
 public class TestParameters extends DaemonParameters {
     static final int TEST_MIN_THREADS = 2;
@@ -26,7 +28,7 @@ public class TestParameters extends DaemonParameters {
     public TestParameters(Path testDir, Path mvndPropertiesPath, Path mavenHome, Path userHome, Path userDir,
             Path multiModuleProjectDirectory,
             Path javaHome, Path localMavenRepository, Path settings, Path logbackConfigurationPath,
-            int idleTimeout, int keepAlive, int maxLostKeepAlive) {
+            Duration idleTimeout, Duration keepAlive, int maxLostKeepAlive) {
         super(new PropertiesBuilder().put(Environment.MVND_PROPERTIES_PATH, mvndPropertiesPath)
                 .put(Environment.MVND_HOME, mavenHome)
                 .put(Environment.USER_HOME, userHome)
@@ -36,8 +38,8 @@ public TestParameters(Path testDir, Path mvndPropertiesPath, Path mavenHome, Pat
                 .put(Environment.MAVEN_REPO_LOCAL, localMavenRepository)
                 .put(Environment.MAVEN_SETTINGS, settings)
                 .put(Environment.LOGBACK_CONFIGURATION_FILE, logbackConfigurationPath)
-                .put(Environment.DAEMON_IDLE_TIMEOUT_MS, idleTimeout)
-                .put(Environment.DAEMON_KEEP_ALIVE_MS, keepAlive)
+                .put(Environment.DAEMON_IDLE_TIMEOUT, TimeUtils.printDuration(idleTimeout))
+                .put(Environment.DAEMON_KEEP_ALIVE, TimeUtils.printDuration(keepAlive))
                 .put(Environment.DAEMON_MAX_LOST_KEEP_ALIVE, maxLostKeepAlive)
                 .put(Environment.MVND_MIN_THREADS, TEST_MIN_THREADS));
         this.testDir = testDir;