Repository: railwayapp/nixpacks
Pull request #265 (2022-07-07 21:31:35)

Hash default cache key (shorter)
The default cache key is the absolute path of the directory being built. This PR shortens that value by
- Hashing it with the Rust `DefaultHasher`
- Converting to base64
- Removing non-alphanumeric characters

This means the cache key is still deterministic but much shorter.

Keys look something like `2UWr73QvCk`


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 25cbfcf..c13550a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -43,6 +43,12 @@ version = "1.1.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
 
+[[package]]
+name = "base64"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+
 [[package]]
 name = "bitflags"
 version = "1.3.2"
@@ -263,6 +269,7 @@ name = "nixpacks"
 version = "0.1.9"
 dependencies = [
  "anyhow",
+ "base64",
  "clap",
  "colored",
  "fs_extra",
diff --git a/Cargo.toml b/Cargo.toml
index ced0529..8c2b1d0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,3 +37,4 @@ thiserror = "1.0.30"
 toml = "0.5.8"
 uuid = { version = "0.8.2", features = ["v4"] }
 wait-timeout = "0.2.0"
+base64 = "0.13.0"
diff --git a/docs/components/Heading.tsx b/docs/components/Heading.tsx
index 1c41417..d1e8843 100644
--- a/docs/components/Heading.tsx
+++ b/docs/components/Heading.tsx
@@ -1,4 +1,5 @@
-import * as React from "react";
+import Link from "next/link";
+import React from "react";
 
 export const Heading: React.FC<{
   id: string;
@@ -9,9 +10,40 @@ export const Heading: React.FC<{
   return React.createElement(
     `h${level}`,
     {
-      id,
-      className: ["heading", className].filter(Boolean).join(" "),
+      className: ["heading", "group", "max-w-max", className]
+        .filter(Boolean)
+        .join(" "),
     },
-    children
+    <HeadingContent id={id}>{children}</HeadingContent>
+  );
+};
+
+const HeadingContent: React.FC<{ id: string; children: React.ReactNode }> = ({
+  id = "",
+  children,
+}) => {
+  return (
+    <>
+      <span
+        id={id}
+        aria-hidden="true"
+        className="absolute inline-block w-px heading-anchor"
+        style={{
+          marginTop: "calc(-1 * (var(--top-nav-height) + 2rem))",
+        }}
+      />
+      <Link href={`#${id}`} passHref>
+        <a
+          className="no-underline hover:text-current"
+          style={{ fontWeight: "inherit" }}
+        >
+          {children}
+
+          <span className="ml-2 font-mono font-semibold text-gray-300 opacity-0 group-hover:opacity-100">
+            #
+          </span>
+        </a>
+      </Link>
+    </>
   );
 };
diff --git a/docs/components/TableOfContents.tsx b/docs/components/TableOfContents.tsx
index 5360adb..49f14fb 100644
--- a/docs/components/TableOfContents.tsx
+++ b/docs/components/TableOfContents.tsx
@@ -1,71 +1,145 @@
-import React from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import Link from "next/link";
+import debounce from "lodash.debounce";
+import { useIsMounted } from "../hooks/useIsMounted";
+import { useRouter } from "next/router";
+import { GITHUB_EDIT_URL } from "../constants";
+import { ArrowRight } from "react-feather";
 
-export const TableOfContents: React.FC<{ toc: any }> = ({ toc }) => {
+const useActiveHeaderId = () => {
+  const [hashHeaderId, setHashHeaderId] = useState(() =>
+    typeof window !== "undefined" && window.location.hash !== ""
+      ? window.location.hash.replace("#", "")
+      : null
+  );
+
+  const [activeHeaderId, setActiveHeaderId] = useState<string | null>(
+    hashHeaderId
+  );
+
+  const onChangeHash = (id: string) => {
+    setHashHeaderId(id);
+    setActiveHeaderId(id);
+  };
+
+  const router = useRouter();
+  useEffect(() => {
+    const handleComplete = () => {
+      setHashHeaderId(null);
+      setActiveHeaderId(null);
+    };
+
+    router.events.on("routeChangeComplete", handleComplete);
+    router.events.on("routeChangeError", handleComplete);
+
+    return () => {
+      router.events.off("routeChangeComplete", handleComplete);
+      router.events.off("routeChangeError", handleComplete);
+    };
+  }, [router]);
+
+  useEffect(() => {
+    if (hashHeaderId != null) {
+      setActiveHeaderId(hashHeaderId);
+      return;
+    }
+
+    const handleScroll = debounce(() => {
+      const headings = Array.from(
+        document.querySelectorAll("main .heading .heading-anchor")
+      );
+      const visibleHeadings = headings.filter(
+        (h) => h.getBoundingClientRect().top >= 10
+      );
+
+      const topHeading = visibleHeadings[0];
+      if (topHeading != null) {
+        setActiveHeaderId(topHeading.id);
+      }
+    }, 50);
+
+    window.addEventListener("scroll", handleScroll);
+
+    return () => {
+      window.removeEventListener("scroll", handleScroll);
+    };
+  }, [hashHeaderId]);
+
+  return { activeHeaderId, onChangeHash };
+};
+
+export const TableOfContents: React.FC<{
+  toc: any;
+  currentFile: string;
+  className?: string;
+}> = ({ toc, currentFile, className }) => {
   const items = toc.filter(
     (item: any) => item.id && (item.level === 2 || item.level === 3)
   );
 
-  if (items.length <= 1) {
+  const { activeHeaderId, onChangeHash } = useActiveHeaderId();
+  const editPageURL = `${GITHUB_EDIT_URL}${currentFile}`;
+
+  // Don't render unless we are mounted so that we can use the window hash
+  const isMounted = useIsMounted();
+  if (!isMounted || items.length <= 1) {
     return null;
   }
 
   return (
-    <nav className="toc">
-      <ul className="flex column">
-        {items.map((item: any) => {
-          const href = `#${item.id}`;
-          const active =
-            typeof window !== "undefined" && window.location.hash === href;
-          return (
-            <li
-              key={item.title}
-              className={[
-                active ? "active" : undefined,
-                item.level === 3 ? "padded" : undefined,
-              ]
-                .filter(Boolean)
-                .join(" ")}
-            >
-              <Link href={href} passHref>
-                <a>{item.title}</a>
-              </Link>
-            </li>
-          );
-        })}
-      </ul>
-      <style jsx>
-        {`
-          nav {
-            position: sticky;
-            top: calc(2.5rem + var(--top-nav-height));
-            max-height: calc(100vh - var(--top-nav-height));
-            flex: 0 0 auto;
-            align-self: flex-start;
-            margin-bottom: 1rem;
-            padding: 0.5rem 0 0;
-            border-left: 1px solid var(--border-color);
-          }
-          ul {
-            margin: 0;
-            padding: 0 1.5rem;
-          }
-          li {
-            list-style-type: none;
-            margin: 0 0 1rem;
-          }
-          li a {
-            text-decoration: none;
-          }
-          li a:hover,
-          li.active a {
-            text-decoration: underline;
-          }
-          li.padded {
-            padding-left: 1rem;
-          }
-        `}
-      </style>
+    <nav className={`toc w-56 px-4 ${className}`}>
+      <div
+        className="sticky pt-20"
+        style={{
+          height: "calc(100vh - var(--top-nav-height) - 4px)",
+          top: "calc(var(--top-nav-height) + 4px)",
+        }}
+      >
+        <p className="mb-4 text-sm font-semibold">On this page</p>
+
+        <ul className="space-y-2 font-mono text-sm">
+          {items.map((item: any) => {
+            const href = `#${item.id}`;
+            const active = item.level !== 1 && activeHeaderId === item.id;
+
+            return (
+              <li key={item.title}>
+                <Link href={href} passHref>
+                  <a
+                    onClick={() => onChangeHash(item.id)}
+                    className={[
+                      active
+                        ? "text-fuchsia-700"
+                        : "text-gray-500 hover:text-fg",
+                      item.level === 1
+                        ? "font-semibold"
+                        : item.level === 2
+                        ? "font-medium"
+                        : "pl-6",
+                    ]
+                      .filter(Boolean)
+                      .join(" ")}
+                  >
+                    {item.title}
+                  </a>
+                </Link>
+              </li>
+            );
+          })}
+        </ul>
+
+        <hr className="my-8" />
+
+        <a
+          href={editPageURL}
+          target="_blank"
+          rel="noopener noreferrer"
+          className="flex items-center text-xs text-gray-500 hover:text-fuchsia-700"
+        >
+          Edit this page on GitHub
+          <ArrowRight size={14} className="ml-2 text-current" />
+        </a>
+      </div>
     </nav>
   );
 };
diff --git a/docs/constants.ts b/docs/constants.ts
index e198e4e..8e9fd22 100644
--- a/docs/constants.ts
+++ b/docs/constants.ts
@@ -1,1 +1,4 @@
 export const GITHUB_URL = "https://github.com/railwayapp/nixpacks";
+
+export const GITHUB_EDIT_URL =
+  "https://github.com/railwayapp/nixpacks/edit/main/docs/pages";
diff --git a/docs/hooks/useIsMounted.ts b/docs/hooks/useIsMounted.ts
new file mode 100644
index 0000000..45b401f
--- /dev/null
+++ b/docs/hooks/useIsMounted.ts
@@ -0,0 +1,7 @@
+import { useEffect, useState } from "react";
+
+export const useIsMounted = () => {
+  const [isMounted, setIsMounted] = useState(false);
+  useEffect(() => setIsMounted(true), []);
+  return isMounted;
+};
diff --git a/docs/markdoc/nodes/heading.markdoc.ts b/docs/markdoc/nodes/heading.markdoc.ts
index b6486d9..1a5fee6 100644
--- a/docs/markdoc/nodes/heading.markdoc.ts
+++ b/docs/markdoc/nodes/heading.markdoc.ts
@@ -8,6 +8,7 @@ const generateID = (children: any, attributes: any) => {
   return children
     .filter((child: any) => typeof child === "string")
     .join(" ")
+    .replace(/^\d+\.\s*/g, "")
     .replace(/[?]/g, "")
     .replace(/\s+/g, "-")
     .toLowerCase();
diff --git a/docs/package.json b/docs/package.json
index 263794d..3e2a320 100644
--- a/docs/package.json
+++ b/docs/package.json
@@ -11,6 +11,7 @@
     "@headlessui/react": "^1.6.5",
     "@markdoc/markdoc": "^0.1.3",
     "@markdoc/next.js": "^0.1.5",
+    "lodash.debounce": "^4.0.8",
     "next": "^12.2.0",
     "prismjs": "^1.28.0",
     "react": "^18.2.0",
@@ -19,6 +20,7 @@
   },
   "devDependencies": {
     "@tailwindcss/typography": "^0.5.2",
+    "@types/lodash.debounce": "^4.0.7",
     "@types/node": "^18.0.0",
     "@types/prismjs": "^1.26.0",
     "@types/react": "^18.0.14",
diff --git a/docs/pages/_app.tsx b/docs/pages/_app.tsx
index 6faca59..70bd714 100644
--- a/docs/pages/_app.tsx
+++ b/docs/pages/_app.tsx
@@ -2,7 +2,7 @@ import type { AppProps } from "next/app";
 import Head from "next/head";
 import { useRouter } from "next/router";
 import "prismjs";
-import { Hero, SideNav, TopNav } from "../components";
+import { Hero, SideNav, TableOfContents, TopNav } from "../components";
 
 // Import other Prism themes here
 import "prismjs/components/prism-bash.min";
@@ -21,6 +21,7 @@ function collectHeadings(node: any, sections: any[] = []) {
       if (typeof title === "string") {
         sections.push({
           ...node.attributes,
+          // id,
           title,
         });
       }
@@ -57,6 +58,8 @@ export default function MyApp({ Component, pageProps }: AppProps) {
     title = `${title} | Nixpacks`;
   }
 
+  const currentFile = markdoc?.file.path;
+
   const toc = pageProps.markdoc?.content
     ? collectHeadings(pageProps.markdoc.content)
     : [];
@@ -82,7 +85,9 @@ export default function MyApp({ Component, pageProps }: AppProps) {
 
           <div
             className={`max-w-[90rem] mx-auto grid gap-8 ${
-              !isHome ? "grid-cols-1 md:grid-cols-[auto_minmax(0px,1fr)]" : ""
+              !isHome
+                ? "grid-cols-1 md:grid-cols-[auto_minmax(0px,1fr)] lg:grid-cols-[auto_minmax(0px,1fr)_auto]"
+                : ""
             }`}
           >
             {!isHome && <SideNav className="hidden md:block" />}
@@ -95,7 +100,13 @@ export default function MyApp({ Component, pageProps }: AppProps) {
               <Component {...pageProps} />
             </main>
 
-            {/* {!isHome && <TableOfContents toc={toc} />} */}
+            {!isHome && (
+              <TableOfContents
+                toc={toc}
+                className="hidden lg:block"
+                currentFile={currentFile}
+              />
+            )}
           </div>
         </div>
       </div>
diff --git a/docs/pages/docs/config.md b/docs/pages/docs/config.md
index abc17bd..eaee9ec 100644
--- a/docs/pages/docs/config.md
+++ b/docs/pages/docs/config.md
@@ -8,14 +8,16 @@ title: Configuration
 
 Nixpacks can be configured via environment variables. Most of these variables are prefixed with `NIXPACKS_`.
 
-| Variable               | Description                                                                                  |
-| :--------------------- | :------------------------------------------------------------------------------------------- |
-| `NIXPACKS_INSTALL_CMD` | Override the install command to use                                                          |
-| `NIXPACKS_BUILD_CMD`   | Override the build command to use                                                            |
-| `NIXPACKS_START_CMD`   | Override command to run when starting the container                                          |
-| `NIXPACKS_PKGS_CMD`    | Add additional [Nix packages](https://search.nixos.org/packages?channel=unstable) to install |
-| `NIXPACKS_APT_CMD`     | Add additional Apt packages to install                                                       |
-| `NIXPACKS_LIBS_CMD`    | Add additional Nix libraries to make available                                               |
+| Variable                      | Description                                                                                  |
+| :---------------------------- | :------------------------------------------------------------------------------------------- |
+| `NIXPACKS_INSTALL_CMD`        | Override the install command to use                                                          |
+| `NIXPACKS_BUILD_CMD`          | Override the build command to use                                                            |
+| `NIXPACKS_START_CMD`          | Override command to run when starting the container                                          |
+| `NIXPACKS_PKGS_CMD`           | Add additional [Nix packages](https://search.nixos.org/packages?channel=unstable) to install |
+| `NIXPACKS_APT_CMD`            | Add additional Apt packages to install                                                       |
+| `NIXPACKS_LIBS_CMD`           | Add additional Nix libraries to make available                                               |
+| `NIXPACKS_INSTALL_CACHE_DIRS` | Add additional directories to cache during the install phase                                 |
+| `NIXPACKS_BUILD_CACHE_DIRS`   | Add additional directories to cache during the build phase                                   |
 
 ## Procfiles
 
diff --git a/docs/pages/docs/how-it-works.md b/docs/pages/docs/how-it-works.md
index bf5fbef..f6f9206 100644
--- a/docs/pages/docs/how-it-works.md
+++ b/docs/pages/docs/how-it-works.md
@@ -6,13 +6,13 @@ title: How It Works
 
 Nixpacks works in two steps
 
-## 1. Plan
+## Plan
 
 Analyze the app source directory and generates a reproducible build plan. This plan can be saved (in JSON format) and re-used at a later date to build the image in the exact same way every time.
 
 To create the plan, language providers are matched against the app source directory and suggest Nix packages, an install command, build command, and start command. All of these can be overwritten by the user.
 
-## 2. Build
+## Build
 
 The build step takes the build plan and creates an OCI compliant image (with Docker) that can be deployed and run anywhere. This happens in the following steps
 
diff --git a/docs/pages/docs/providers/node.md b/docs/pages/docs/providers/node.md
index b2d667a..77a07f5 100644
--- a/docs/pages/docs/providers/node.md
+++ b/docs/pages/docs/providers/node.md
@@ -30,6 +30,10 @@ The version can be overriden by
 
 Only a major version can be specified. For example, `14.x` or `14`.
 
+**Node Canvas**
+
+If [node-canvas](https://www.npmjs.com/package/canvas) is found in the `package.json` file, then the `libuuid` and `libGL` libraries are made available in the environment.
+
 ## Install
 
 All dependencies found in `packages.json` are installed with either NPM, Yarn, or PNPM.
@@ -38,10 +42,6 @@ All dependencies found in `packages.json` are installed with either NPM, Yarn, o
 
 The build script found in `package.json` if it exists.
 
-**Node Canvas**
-
-If [node-canvas](https://www.npmjs.com/package/canvas) is found in the `package.json` file, then the `libuuid` and `libGL` libraries are made available in the environment.
-
 ## Start
 
 The start command priority is
diff --git a/docs/public/globals.css b/docs/public/globals.css
index 42d85a6..4b8b409 100644
--- a/docs/public/globals.css
+++ b/docs/public/globals.css
@@ -16,12 +16,20 @@ body {
   margin: 0;
   padding: 0;
   line-height: 1.5;
-  font-family: "Inter", sans-serif;
   overflow-y: scroll;
 
   -webkit-font-smoothing: antialiased;
+
+  @apply font-sans;
 }
 
 ::selection {
   @apply bg-fuchsia-200;
 }
+
+.heading-anchor {
+  margin-top: calc(-1 * (var(--top-nav-height) + 2rem));
+  display: inline-block;
+  position: absolute;
+  width: 1px;
+}
diff --git a/docs/tailwind.config.js b/docs/tailwind.config.js
index 3307000..29a6ab1 100644
--- a/docs/tailwind.config.js
+++ b/docs/tailwind.config.js
@@ -1,3 +1,29 @@
+const fontStack = [
+  "Inter",
+  "-apple-system",
+  "BlinkMacSystemFont",
+  "Segoe UI",
+  "Roboto",
+  "Oxygen-Sans",
+  "Ubuntu",
+  "Cantarell",
+  "Helvetica Neue",
+  "sans-serif",
+  "Apple Color Emoji",
+  "Segoe UI Emoji",
+  "Segoe UI Symbol",
+].join(",");
+
+const monoStack = [
+  "ui-monospace",
+  "SFMono-Regular",
+  "SF Mono",
+  "Consolas",
+  "Liberation Mono",
+  "Menlo",
+  "monospace",
+].join(",");
+
 /** @type {import('tailwindcss').Config} */
 module.exports = {
   mode: "jit",
@@ -7,6 +33,10 @@ module.exports = {
   ],
   theme: {
     extend: {
+      fontFamily: {
+        sans: fontStack,
+        mono: monoStack,
+      },
       colors: {
         fg: "var(--fg)",
         bg: "var(--bg)",
diff --git a/docs/yarn.lock b/docs/yarn.lock
index 5b0ef64..38fc95b 100644
--- a/docs/yarn.lock
+++ b/docs/yarn.lock
@@ -187,6 +187,18 @@
   resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==
 
+"@types/lodash.debounce@^4.0.7":
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.7.tgz#0285879defb7cdb156ae633cecd62d5680eded9f"
+  integrity sha512-X1T4wMZ+gT000M2/91SYj0d/7JfeNZ9PeeOldSNoE/lunLeQXKvkmIumI29IaKMotU/ln/McOIvgzZcQ/3TrSA==
+  dependencies:
+    "@types/lodash" "*"
+
+"@types/lodash@*":
+  version "4.14.182"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
+  integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
+
 "@types/node@^18.0.0":
   version "18.0.0"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.0.tgz#67c7b724e1bcdd7a8821ce0d5ee184d3b4dd525a"
@@ -1325,6 +1337,11 @@ lodash.castarray@^4.4.0:
   resolved "https://registry.yarnpkg.com/lodash.castarray/-/lodash.castarray-4.4.0.tgz#c02513515e309daddd4c24c60cfddcf5976d9115"
   integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==
 
+lodash.debounce@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+  integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
+
 lodash.isplainobject@^4.0.6:
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
diff --git a/src/main.rs b/src/main.rs
index cc39c2a..e830540 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,4 +1,8 @@
-use std::env;
+use std::{
+    collections::hash_map::DefaultHasher,
+    env,
+    hash::{Hash, Hasher},
+};
 
 use anyhow::Result;
 use clap::{arg, Arg, Command};
@@ -157,6 +161,7 @@ fn main() -> Result<()> {
         Some(values) => values.map(String::from).collect::<Vec<String>>(),
         None => Vec::new(),
     };
+
     let pin_pkgs = matches.is_present("pin");
 
     let envs: Vec<_> = match matches.values_of("env") {
@@ -194,11 +199,7 @@ fn main() -> Result<()> {
 
             // Default to absolute `path` of the source that is being built as the cache-key if not disabled
             if !no_cache && cache_key.is_none() {
-                let current_dir = env::current_dir()?;
-                let source = current_dir.join(path).canonicalize();
-                if let Ok(source) = source {
-                    cache_key = Some(source.to_string_lossy().to_string())
-                }
+                cache_key = get_default_cache_key(path)?;
             }
 
             let tags = matches
@@ -227,3 +228,32 @@ fn main() -> Result<()> {
 
     Ok(())
 }
+
+fn get_default_cache_key(path: &str) -> Result<Option<String>> {
+    let current_dir = env::current_dir()?;
+    let source = current_dir.join(path).canonicalize();
+    if let Ok(source) = source {
+        let source_str = source.to_string_lossy().to_string();
+        let mut hasher = DefaultHasher::new();
+        source_str.hash(&mut hasher);
+
+        let encoded_source = base64::encode(hasher.finish().to_be_bytes())
+            .replace(|c: char| !c.is_alphanumeric(), "");
+
+        Ok(Some(encoded_source))
+    } else {
+        Ok(None)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_get_default_cache_key() {
+        let path = "./examples/node";
+        let cache_key = get_default_cache_key(path).unwrap();
+        assert_eq!(cache_key, Some("2UWr73QvCk".to_string()));
+    }
+}
diff --git a/src/nixpacks/app.rs b/src/nixpacks/app.rs
index 9908f71..9770210 100644
--- a/src/nixpacks/app.rs
+++ b/src/nixpacks/app.rs
@@ -186,11 +186,11 @@ impl App {
 
 #[cfg(test)]
 mod tests {
-    use std::collections::HashMap;
-
     use super::*;
+
     use serde::{Deserialize, Serialize};
     use serde_json::{Map, Value};
+    use std::collections::HashMap;
 
     #[derive(Serialize, Deserialize)]
     struct TestPackageJson {
diff --git a/src/nixpacks/plan/generator.rs b/src/nixpacks/plan/generator.rs
index dfc4d41..5389c76 100644
--- a/src/nixpacks/plan/generator.rs
+++ b/src/nixpacks/plan/generator.rs
@@ -164,6 +164,18 @@ impl<'a> NixpacksBuildPlanGenerator<'a> {
             env_install_cmd = Some(vec![install_cmd]);
         }
 
+        if let Some(install_cache_dirs) = environment.get_config_variable("INSTALL_CACHE_DIRS") {
+            let custom_install_cache_dirs = install_cache_dirs
+                .split(',')
+                .map(|s| s.to_string())
+                .collect::<Vec<_>>();
+
+            install_phase.cache_directories = match install_phase.cache_directories {
+                Some(dirs) => Some([dirs, custom_install_cache_dirs].concat()),
+                None => Some(custom_install_cache_dirs),
+            }
+        }
+
         // Start command priority
         // - custom install command
         // - environment variable
@@ -190,6 +202,18 @@ impl<'a> NixpacksBuildPlanGenerator<'a> {
             env_build_cmd = Some(vec![build_cmd]);
         }
 
+        if let Some(build_cache_dirs) = environment.get_config_variable("BUILD_CACHE_DIRS") {
+            let custom_build_cache_dirs = build_cache_dirs
+                .split(',')
+                .map(|s| s.to_string())
+                .collect::<Vec<_>>();
+
+            build_phase.cache_directories = match build_phase.cache_directories {
+                Some(dirs) => Some([dirs, custom_build_cache_dirs].concat()),
+                None => Some(custom_build_cache_dirs),
+            }
+        }
+
         // Build command priority
         // - custom build command
         // - environment variable


Test patch:
diff --git a/tests/generate_plan_tests.rs b/tests/generate_plan_tests.rs
index 2127cb8..09e05ed 100644
--- a/tests/generate_plan_tests.rs
+++ b/tests/generate_plan_tests.rs
@@ -558,19 +558,36 @@ fn test_config_from_environment_variables() -> Result<()> {
             "NIXPACKS_BUILD_CMD=build",
             "NIXPACKS_START_CMD=start",
             "NIXPACKS_RUN_IMAGE=alpine",
+            "NIXPACKS_INSTALL_CACHE_DIRS=/tmp,foobar",
+            "NIXPACKS_BUILD_CACHE_DIRS=/build,barbaz",
         ],
         &GeneratePlanOptions::default(),
     )?;
+
     assert_eq!(
-        plan.install.unwrap().cmds,
+        plan.setup.unwrap().pkgs,
+        vec![Pkg::new("cowsay"), Pkg::new("ripgrep")]
+    );
+
+    assert_eq!(
+        plan.install.clone().unwrap().cmds,
         Some(vec!["install".to_string()])
     );
-    assert_eq!(plan.build.unwrap().cmds, Some(vec!["build".to_string()]));
-    assert_eq!(plan.start.clone().unwrap().cmd, Some("start".to_string()));
     assert_eq!(
-        plan.setup.unwrap().pkgs,
-        vec![Pkg::new("cowsay"), Pkg::new("ripgrep")]
+        plan.install.unwrap().cache_directories,
+        Some(vec!["/tmp".to_string(), "foobar".to_string()])
+    );
+
+    assert_eq!(
+        plan.build.clone().unwrap().cmds,
+        Some(vec!["build".to_string()])
     );
+    assert_eq!(
+        plan.build.unwrap().cache_directories,
+        Some(vec!["/build".to_string(), "barbaz".to_string()])
+    );
+
+    assert_eq!(plan.start.clone().unwrap().cmd, Some("start".to_string()));
     assert_eq!(plan.start.unwrap().run_image, Some("alpine".to_string()));
 
     Ok(())