Repository: railwayapp/nixpacks
Pull request #259 (2022-07-06 07:54:19)

BuildKit Caching
This PR implements incremental caching by utilizing [BuildKit mount caching](https://github.com/moby/buildkit/blob/v0.10.0/frontend/dockerfile/docs/syntax.md#run---mounttypecache). It replaces #210, which used a custom approach to caching directories between builds in favor of a more Docker native approach (although we may still need a custom solution in the future).

To enable caching you can provide a `--cache-key` arg to the `build` command. If set, this key will be used as a prefix for the "id" mount option.

BuildKit caching works by caching a certain directory(ies) for `RUN` commands. The directory is restored before the command is run and wiped after. **This means that the contents of the cache directory do not appear in the final image**.

Currently, the caching only works locally in the same way that layer caching works. However, I am planning on expanding the functionality to enable [using a remote image as the cache source](https://dille.name/slides/2020-01-22/020_advanced/025_build_cache/buildkit.final/).

Example

```
nixpacks build . --cache-key my-cache-key
```

### Provider Caching

Install and build phases have a new API: `add_cache_directory(&mut self, dir: String)` that providers can use to cache a specific directory for either the install or build phases. This PR implements caching for

- Node
  + Install: Package (NPM/Yarn/PNPM) cache directories, Cypress global cache
  + Build: NextJS `.next/cache` directories, `node_modules/.cache` directory
- Python
  + Install: Pip global cache
- Rust
  + Build: `~/.cargo/git`, `~/.cargo/registry`, `/app/target`

### Results

Don't take these results too seriously. They are mainly here to illustrate that caching is actually beneficial to the builds.

Building a monorepo frontend NextJS app with the following command

```
cargo run -- build examples/node-monorepo --build-cmd "yarn workspace client build" --start-cmd "yarn workspace client start" --name monorepo --cache-key cache-key
```

| Phase | Without Cache | With Cache | Improvement |
| :--- | :--- | :--- | :--- |
| Install | 19.9s | 2.9s | 85% |
| Build | 8.0s | 4.6s | 43% |

Building a starter Django application


```
cargo run -- build examples/python-django --name django --cache-key d1
```

| Phase | Without Cache | With Cache | Improvement |
| :--- | :--- | :--- | :--- |
| Install | 14.6s | 9.6s | 38% |

Building a Rust rocket app with no musl

```
cargo run -- build examples/rust-rocket --name rocket --cache-key rr1 --env "NIXPACKS_NO_MUSL=1"
```

| Phase | Without Cache | With Cache | Improvement |
| :--- | :--- | :--- | :--- |
| Build | 80.6s | 0.3s | 99% |

_Note: The cache layer for rust dependencies is fairly large_

## Additional Changes

- Force buildkit if `cache-key` is set
- Fixed the staticfile example
- `find_directories` app API that is similar to the `find_files` method
- Copy binary out of Rust target dir 

Closes https://github.com/railwayapp/nixpacks/issues/201


Patch:
diff --git a/examples/staticfile/Staticfile b/examples/staticfile/Staticfile
index b399989..0ea46a9 100644
--- a/examples/staticfile/Staticfile
+++ b/examples/staticfile/Staticfile
@@ -1,1 +1,1 @@
-root: /app/site
\ No newline at end of file
+root: site
diff --git a/src/lib.rs b/src/lib.rs
index 2f9b5d7..60263ea 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -41,8 +41,8 @@ pub fn get_providers() -> Vec<&'static dyn Provider> {
         &NodeProvider {},
         &PythonProvider {},
         &RustProvider {},
-        &StaticfileProvider {},
         &SwiftProvider {},
+        &StaticfileProvider {},
         &ZigProvider {},
     ]
 }
diff --git a/src/main.rs b/src/main.rs
index f237fba..2093bf2 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,5 @@
+use std::env;
+
 use anyhow::Result;
 use clap::{arg, Arg, Command};
 use nixpacks::{
@@ -54,10 +56,17 @@ fn main() -> Result<()> {
                         .multiple_values(true),
                 )
                 .arg(
-                    Arg::new("buildkit")
-                        .long("buildkit")
-                        .help("Forces docker to use buildkit")
-                        .takes_value(false),
+                    Arg::new("cache-key")
+                        .long("cache-key")
+                        .help(
+                            "Unique identifier to key cache by. Defaults to the current directory",
+                        )
+                        .takes_value(true),
+                )
+                .arg(
+                    Arg::new("no-cache")
+                        .long("no-cache")
+                        .help("Disable building with the cache"),
                 ),
         )
         .arg(
@@ -180,6 +189,20 @@ fn main() -> Result<()> {
             let path = matches.value_of("PATH").expect("required");
             let name = matches.value_of("name").map(|n| n.to_string());
             let out_dir = matches.value_of("out").map(|n| n.to_string());
+            let mut cache_key = matches.value_of("cache-key").map(|n| n.to_string());
+            let no_cache = matches.is_present("no-cache");
+
+            // 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();
+                match source {
+                    Ok(source) => cache_key = Some(source.to_string_lossy().to_string()),
+                    _ => {}
+                }
+            }
+
+            println!("Cache key: {:?}", cache_key);
 
             let tags = matches
                 .values_of("tag")
@@ -191,15 +214,13 @@ fn main() -> Result<()> {
                 .map(|values| values.map(|s| s.to_string()).collect::<Vec<_>>())
                 .unwrap_or_default();
 
-            let force_buildkit = matches.is_present("buildkit");
-
             let build_options = &DockerBuilderOptions {
                 name,
                 tags,
                 labels,
                 out_dir,
-                force_buildkit,
                 quiet: false,
+                cache_key,
             };
 
             create_docker_image(path, envs, plan_options, build_options)?;
diff --git a/src/nixpacks/app.rs b/src/nixpacks/app.rs
index 290a2d7..9006252 100644
--- a/src/nixpacks/app.rs
+++ b/src/nixpacks/app.rs
@@ -42,6 +42,30 @@ impl App {
     /// # Errors
     /// Creating the Glob fails
     pub fn find_files(&self, pattern: &str) -> Result<Vec<PathBuf>> {
+        let directories = self
+            .find_glob(pattern)?
+            .into_iter()
+            .filter(|path| path.is_file())
+            .collect();
+
+        Ok(directories)
+    }
+
+    /// Returns a list of paths matching a glob pattern
+    ///
+    /// # Errors
+    /// Creating the Glob fails
+    pub fn find_directories(&self, pattern: &str) -> Result<Vec<PathBuf>> {
+        let directories = self
+            .find_glob(pattern)?
+            .into_iter()
+            .filter(|path| path.is_dir())
+            .collect();
+
+        Ok(directories)
+    }
+
+    fn find_glob(&self, pattern: &str) -> Result<Vec<PathBuf>> {
         let full_pattern = self.source.join(pattern);
 
         let pattern_str = match full_pattern.to_str() {
@@ -57,7 +81,7 @@ impl App {
             .into_iter()
             .filter_map(|result| result.ok()) // remove bad ones
             .map(|dir| dir.into_path()) // convert to paths
-            .filter(|path| glob.is_match(path) && path.is_file()) // find matches
+            .filter(|path| glob.is_match(path)) // find matches
             .collect();
 
         Ok(relative_paths)
@@ -131,6 +155,7 @@ impl App {
         Ok(yaml_file)
     }
 
+    /// Convert an absolute path to a path relative to the app source directory
     pub fn strip_source_path(&self, abs_path: &Path) -> Result<PathBuf> {
         let source_str = match self.source.to_str() {
             Some(s) => s,
diff --git a/src/nixpacks/builder/docker.rs b/src/nixpacks/builder/docker.rs
index 703e2fd..ea7a45b 100644
--- a/src/nixpacks/builder/docker.rs
+++ b/src/nixpacks/builder/docker.rs
@@ -6,7 +6,9 @@ use std::{
 };
 
 use super::Builder;
-use crate::nixpacks::{app, files, logger::Logger, nix, plan::BuildPlan, NIX_PACKS_VERSION};
+use crate::nixpacks::{
+    app, cache::sanitize_cache_key, files, logger::Logger, nix, plan::BuildPlan, NIX_PACKS_VERSION,
+};
 use anyhow::{bail, Context, Ok, Result};
 use indoc::formatdoc;
 use tempdir::TempDir;
@@ -19,7 +21,7 @@ pub struct DockerBuilderOptions {
     pub tags: Vec<String>,
     pub labels: Vec<String>,
     pub quiet: bool,
-    pub force_buildkit: bool,
+    pub cache_key: Option<String>,
 }
 
 pub struct DockerBuilder {
@@ -90,9 +92,9 @@ impl DockerBuilder {
             bail!("Please install Docker to build the app https://docs.docker.com/engine/install/")
         }
 
-        if self.options.force_buildkit {
-            docker_build_cmd.env("DOCKER_BUILDKIT", "1");
-        }
+        // Enable BuildKit for all builds
+        docker_build_cmd.env("DOCKER_BUILDKIT", "1");
+
         docker_build_cmd.arg("build").arg(dest).arg("-t").arg(name);
 
         if self.options.quiet {
@@ -230,11 +232,14 @@ impl DockerBuilder {
         };
 
         // -- Install
+        let install_cache_mount =
+            get_cache_mount(&self.options.cache_key, &install_phase.cache_directories);
+
         let install_cmd = install_phase
             .cmds
             .unwrap_or_default()
             .iter()
-            .map(|c| format!("RUN {}", c))
+            .map(|c| format!("RUN {} {}", install_cache_mount, c))
             .collect::<Vec<String>>()
             .join("\n");
 
@@ -256,11 +261,14 @@ impl DockerBuilder {
             .unwrap_or_else(|| vec![".".to_string()]);
 
         // -- Build
+        let build_cache_mount =
+            get_cache_mount(&self.options.cache_key, &build_phase.cache_directories);
+
         let build_cmd = build_phase
             .cmds
             .unwrap_or_default()
             .iter()
-            .map(|c| format!("RUN {}", c))
+            .map(|c| format!("RUN {} {}", build_cache_mount, c))
             .collect::<Vec<String>>()
             .join("\n");
 
@@ -343,6 +351,19 @@ impl DockerBuilder {
     }
 }
 
+fn get_cache_mount(cache_key: &Option<String>, cache_directories: &Option<Vec<String>>) -> String {
+    let sanitized_cache_key = cache_key.clone().map(|key| sanitize_cache_key(key));
+
+    match (sanitized_cache_key, cache_directories) {
+        (Some(cache_key), Some(cache_directories)) => cache_directories
+            .iter()
+            .map(|dir| format!("--mount=type=cache,id={cache_key}-{dir},target={dir}"))
+            .collect::<Vec<String>>()
+            .join(" "),
+        _ => "".to_string(),
+    }
+}
+
 fn get_copy_command(files: &[String], app_dir: &str) -> String {
     if files.is_empty() {
         "".to_owned()
@@ -367,3 +388,30 @@ fn get_copy_from_command(from: &str, files: &[String], app_dir: &str) -> String
         )
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_get_cache_mount() {
+        let cache_key = Some("cache_key".to_string());
+        let cache_directories = Some(vec!["dir1".to_string(), "dir2".to_string()]);
+
+        let expected = "--mount=type=cache,id=cache_key-dir1,target=dir1 --mount=type=cache,id=cache_key-dir2,target=dir2";
+        let actual = get_cache_mount(&cache_key, &cache_directories);
+
+        assert_eq!(expected, actual);
+    }
+
+    #[test]
+    fn test_get_cache_mount_invalid_cache_key() {
+        let cache_key = Some("my cache key".to_string());
+        let cache_directories = Some(vec!["dir1".to_string(), "dir2".to_string()]);
+
+        let expected = "--mount=type=cache,id=my-cache-key-dir1,target=dir1 --mount=type=cache,id=my-cache-key-dir2,target=dir2";
+        let actual = get_cache_mount(&cache_key, &cache_directories);
+
+        assert_eq!(expected, actual);
+    }
+}
diff --git a/src/nixpacks/cache.rs b/src/nixpacks/cache.rs
new file mode 100644
index 0000000..ea813c8
--- /dev/null
+++ b/src/nixpacks/cache.rs
@@ -0,0 +1,17 @@
+pub fn sanitize_cache_key(cache_key: String) -> String {
+    cache_key.replace(" ", "-")
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_sanitizing_cache_key() {
+        assert_eq!(sanitize_cache_key("key".to_string()), "key".to_string());
+        assert_eq!(
+            sanitize_cache_key("s p a c e s".to_string()),
+            "s-p-a-c-e-s".to_string()
+        );
+    }
+}
diff --git a/src/nixpacks/mod.rs b/src/nixpacks/mod.rs
index 303b94f..18ce501 100644
--- a/src/nixpacks/mod.rs
+++ b/src/nixpacks/mod.rs
@@ -1,5 +1,6 @@
 pub mod app;
 pub mod builder;
+mod cache;
 pub mod environment;
 mod files;
 pub mod images;
diff --git a/src/nixpacks/phase.rs b/src/nixpacks/phase.rs
index 5474e10..64e26d9 100644
--- a/src/nixpacks/phase.rs
+++ b/src/nixpacks/phase.rs
@@ -99,6 +99,9 @@ pub struct InstallPhase {
     #[serde(rename = "onlyIncludeFiles")]
     pub only_include_files: Option<Vec<String>>,
 
+    #[serde(rename = "cacheDirectories")]
+    pub cache_directories: Option<Vec<String>>,
+
     pub paths: Option<Vec<String>>,
 }
 
@@ -107,6 +110,7 @@ impl InstallPhase {
         Self {
             cmds: Some(vec![cmd]),
             only_include_files: None,
+            cache_directories: None,
             paths: None,
         }
     }
@@ -120,6 +124,15 @@ impl InstallPhase {
         }
     }
 
+    pub fn add_cache_directory(&mut self, dir: String) {
+        if let Some(mut dirs) = self.cache_directories.clone() {
+            dirs.push(dir);
+            self.cache_directories = Some(dirs);
+        } else {
+            self.cache_directories = Some(vec![dir]);
+        }
+    }
+
     pub fn add_path(&mut self, path: String) {
         if let Some(mut paths) = self.paths.clone() {
             paths.push(path);
@@ -146,6 +159,9 @@ pub struct BuildPhase {
 
     #[serde(rename = "onlyIncludeFiles")]
     pub only_include_files: Option<Vec<String>>,
+
+    #[serde(rename = "cacheDirectories")]
+    pub cache_directories: Option<Vec<String>>,
 }
 
 impl BuildPhase {
@@ -153,6 +169,7 @@ impl BuildPhase {
         Self {
             cmds: Some(vec![cmd]),
             only_include_files: None,
+            cache_directories: None,
         }
     }
 
@@ -165,6 +182,15 @@ impl BuildPhase {
         }
     }
 
+    pub fn add_cache_directory(&mut self, dir: String) {
+        if let Some(mut dirs) = self.cache_directories.clone() {
+            dirs.push(dir);
+            self.cache_directories = Some(dirs);
+        } else {
+            self.cache_directories = Some(vec![dir]);
+        }
+    }
+
     pub fn add_cmd(&mut self, cmd: String) {
         if let Some(mut cmds) = self.cmds.clone() {
             cmds.push(cmd);
diff --git a/src/providers/node.rs b/src/providers/node.rs
index b2e928a..cc22f45 100644
--- a/src/providers/node.rs
+++ b/src/providers/node.rs
@@ -1,4 +1,4 @@
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 use super::Provider;
 use crate::nixpacks::{
@@ -14,12 +14,21 @@ use serde::{Deserialize, Serialize};
 const DEFAULT_NODE_PKG_NAME: &'static &str = &"nodejs";
 const AVAILABLE_NODE_VERSIONS: &[u32] = &[10, 12, 14, 16, 18];
 
+const YARN_CACHE_DIR: &'static &str = &"/usr/local/share/.cache/yarn/v6";
+const PNPM_CACHE_DIR: &'static &str = &"/root/.cache/pnpm";
+const NPM_CACHE_DIR: &'static &str = &"/root/.npm";
+const CYPRESS_CACHE_DIR: &'static &str = &"/root/.cache/Cypress";
+const NODE_MODULES_CACHE_DIR: &'static &str = &"node_modules/.cache";
+
 #[derive(Serialize, Deserialize, Default, Debug)]
 pub struct PackageJson {
     pub name: Option<String>,
     pub scripts: Option<HashMap<String, String>>,
     pub engines: Option<HashMap<String, String>>,
     pub main: Option<String>,
+    pub dependencies: Option<HashMap<String, String>>,
+    #[serde(rename = "devDependencies")]
+    pub dev_dependencies: Option<HashMap<String, String>>,
 }
 
 pub struct NodeProvider {}
@@ -44,16 +53,52 @@ impl Provider for NodeProvider {
 
     fn install(&self, app: &App, _env: &Environment) -> Result<Option<InstallPhase>> {
         let install_cmd = NodeProvider::get_install_command(app);
-        Ok(Some(InstallPhase::new(install_cmd)))
+
+        let mut install_phase = InstallPhase::new(install_cmd);
+
+        // Package manage cache directories
+        let package_manager = NodeProvider::get_package_manager(app);
+        if package_manager == "yarn" {
+            install_phase.add_cache_directory(YARN_CACHE_DIR.to_string());
+        } else if package_manager == "pnpm" {
+            install_phase.add_cache_directory(PNPM_CACHE_DIR.to_string());
+        } else {
+            install_phase.add_cache_directory(NPM_CACHE_DIR.to_string());
+        }
+
+        let all_deps = NodeProvider::get_all_deps(app)?;
+
+        // Cypress cache directory
+        if all_deps.get("cypress").is_some() {
+            install_phase.add_cache_directory(CYPRESS_CACHE_DIR.to_string());
+        }
+
+        Ok(Some(install_phase))
     }
 
     fn build(&self, app: &App, _env: &Environment) -> Result<Option<BuildPhase>> {
+        let mut build_phase = BuildPhase::default();
+
         if NodeProvider::has_script(app, "build")? {
             let pkg_manager = NodeProvider::get_package_manager(app);
-            Ok(Some(BuildPhase::new(pkg_manager + " run build")))
-        } else {
-            Ok(None)
+            build_phase.add_cmd(format!("{} run build", pkg_manager));
+        }
+
+        // Next build cache directories
+        let next_cache_dirs = NodeProvider::find_next_packages(app)?;
+        for dir in next_cache_dirs {
+            let next_cache_dir = ".next/cache";
+            build_phase.add_cache_directory(if dir.is_empty() {
+                next_cache_dir.to_string()
+            } else {
+                format!("{}/{}", dir, next_cache_dir)
+            });
         }
+
+        // Node modules cache directory
+        build_phase.add_cache_directory(NODE_MODULES_CACHE_DIR.to_string());
+
+        Ok(Some(build_phase))
     }
 
     fn start(&self, app: &App, _env: &Environment) -> Result<Option<StartPhase>> {
@@ -209,6 +254,69 @@ impl NodeProvider {
             || yarn_lock.contains("/canvas/")
             || pnpm_yaml.contains("/canvas/")
     }
+
+    pub fn find_next_packages(app: &App) -> Result<Vec<String>> {
+        // Find all package.json files
+        let package_json_files = app.find_files("**/package.json")?;
+
+        let mut cache_dirs: Vec<String> = vec![];
+
+        // Find package.json files with a "next build" build script and cache the associated .next/cache directory
+        for file in package_json_files {
+            let json: PackageJson = app.read_json(file.to_str().unwrap())?;
+
+            let deps = NodeProvider::get_deps_from_package_json(&json);
+
+            if deps.contains("next") {
+                let relative = app.strip_source_path(file.as_path())?;
+                cache_dirs.push(relative.parent().unwrap().to_str().unwrap().to_string());
+            }
+        }
+
+        Ok(cache_dirs)
+    }
+
+    /// Finds all dependencies (dev and non-dev) of all package.json files in the app.
+    pub fn get_all_deps(app: &App) -> Result<HashSet<String>> {
+        // Find all package.json files
+        let package_json_files = app.find_files("**/package.json")?;
+
+        let mut all_deps: HashSet<String> = HashSet::new();
+
+        for file in package_json_files {
+            let json: PackageJson = app.read_json(file.to_str().unwrap())?;
+
+            all_deps.extend(NodeProvider::get_deps_from_package_json(&json));
+        }
+
+        Ok(all_deps)
+    }
+
+    pub fn get_deps_from_package_json(json: &PackageJson) -> HashSet<String> {
+        let mut all_deps: HashSet<String> = HashSet::new();
+
+        let deps = json
+            .dependencies
+            .clone()
+            .map(|deps| deps.keys().map(|k| k.to_string()).collect::<Vec<String>>())
+            .unwrap_or_default();
+
+        let dev_deps = json
+            .dev_dependencies
+            .clone()
+            .map(|dev_deps| {
+                dev_deps
+                    .keys()
+                    .map(|k| k.to_string())
+                    .collect::<Vec<String>>()
+            })
+            .unwrap_or_default();
+
+        all_deps.extend(deps.into_iter());
+        all_deps.extend(dev_deps.into_iter());
+
+        all_deps
+    }
 }
 
 fn version_number_to_pkg(version: &u32) -> String {
@@ -246,9 +354,7 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
-                    engines: None
+                    ..Default::default()
                 },
                 &Environment::default()
             )?,
@@ -264,9 +370,8 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
-                    engines: engines_node("*")
+                    engines: engines_node("*"),
+                    ..Default::default()
                 },
                 &Environment::default()
             )?,
@@ -282,9 +387,8 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
                     engines: engines_node("14"),
+                    ..Default::default()
                 },
                 &Environment::default()
             )?,
@@ -300,9 +404,8 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
                     engines: engines_node("12.x"),
+                    ..Default::default()
                 },
                 &Environment::default()
             )?,
@@ -313,9 +416,8 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
                     engines: engines_node("14.X"),
+                    ..Default::default()
                 },
                 &Environment::default()
             )?,
@@ -331,9 +433,8 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
                     engines: engines_node(">=14.10.3 <16"),
+                    ..Default::default()
                 },
                 &Environment::default()
             )?,
@@ -349,9 +450,7 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
-                    engines: None,
+                    ..Default::default()
                 },
                 &Environment::new(HashMap::from([(
                     "NIXPACKS_NODE_VERSION".to_string(),
@@ -371,9 +470,8 @@ mod test {
             NodeProvider::get_nix_node_pkg(
                 &PackageJson {
                     name: Some(String::default()),
-                    main: None,
-                    scripts: None,
                     engines: engines_node("15"),
+                    ..Default::default()
                 },
                 &Environment::default()
             )
@@ -384,4 +482,20 @@ mod test {
 
         Ok(())
     }
+
+    #[test]
+    fn test_find_next_pacakges() -> Result<()> {
+        assert_eq!(
+            NodeProvider::find_next_packages(&App::new("./examples/node-monorepo")?)?,
+            vec!["packages/client".to_string()]
+        );
+        assert_eq!(
+            NodeProvider::find_next_packages(&App::new(
+                "./examples/node-monorepo/packages/client"
+            )?)?,
+            vec!["".to_string()]
+        );
+
+        Ok(())
+    }
 }
diff --git a/src/providers/python.rs b/src/providers/python.rs
index 659eedd..9bfd960 100644
--- a/src/providers/python.rs
+++ b/src/providers/python.rs
@@ -20,8 +20,9 @@ use crate::{
 use super::Provider;
 
 const DEFAULT_PYTHON_PKG_NAME: &'static &str = &"python38";
-
 const POETRY_VERSION: &'static &str = &"1.1.13";
+const PIP_CACHE_DIR: &'static &str = &"/root/.cache/pip";
+
 pub struct PythonProvider {}
 
 impl Provider for PythonProvider {
@@ -59,8 +60,12 @@ impl Provider for PythonProvider {
                 "{} && {} && pip install -r requirements.txt",
                 create_env, activate_env
             ));
+
             install_phase.add_file_dependency("requirements.txt".to_string());
             install_phase.add_path(format!("{}/bin", env_loc));
+
+            install_phase.add_cache_directory(PIP_CACHE_DIR.to_string());
+
             return Ok(Some(install_phase));
         } else if app.includes_file("pyproject.toml") {
             if app.includes_file("poetry.lock") {
@@ -69,17 +74,25 @@ impl Provider for PythonProvider {
                     "{} && {} && {} && poetry install --no-dev --no-interaction --no-ansi",
                     create_env, activate_env, install_poetry
                 ));
+
                 install_phase.add_file_dependency("poetry.lock".to_string());
                 install_phase.add_file_dependency("pyproject.toml".to_string());
                 install_phase.add_path(format!("{}/bin", env_loc));
+
+                install_phase.add_cache_directory(PIP_CACHE_DIR.to_string());
+
                 return Ok(Some(install_phase));
             }
             let mut install_phase = InstallPhase::new(format!(
                 "{} && {} && pip install --upgrade build setuptools && pip install .",
                 create_env, activate_env
             ));
+
             install_phase.add_file_dependency("pyproject.toml".to_string());
             install_phase.add_path(format!("{}/bin", env_loc));
+
+            install_phase.add_cache_directory(PIP_CACHE_DIR.to_string());
+
             return Ok(Some(install_phase));
         }
 
diff --git a/src/providers/rust.rs b/src/providers/rust.rs
index e17b8f2..cba6d7f 100644
--- a/src/providers/rust.rs
+++ b/src/providers/rust.rs
@@ -13,6 +13,10 @@ use serde::{Deserialize, Serialize};
 static RUST_OVERLAY: &str = "https://github.com/oxalica/rust-overlay/archive/master.tar.gz";
 static DEFAULT_RUST_PACKAGE: &str = "rust-bin.stable.latest.default";
 
+const CARGO_GIT_CACHE_DIR: &'static &str = &"/root/.cargo/git";
+const CARGO_REGISTRY_CACHE_DIR: &'static &str = &"/root/.cargo/registry";
+const CARGO_TARGET_CACHE_DIR: &'static &str = &"target";
+
 #[derive(Serialize, Deserialize, Debug)]
 pub struct CargoTomlPackage {
     pub name: String,
@@ -60,21 +64,48 @@ impl Provider for RustProvider {
     }
 
     fn build(&self, app: &App, env: &Environment) -> Result<Option<BuildPhase>> {
-        let build_phase = match RustProvider::get_target(app, env)? {
-            Some(target) => BuildPhase::new(format!("cargo build --release --target {target}")),
-            None => BuildPhase::new("cargo build --release".to_string()),
+        let mut build_phase = match RustProvider::get_target(app, env)? {
+            Some(target) => {
+                let mut build_phase =
+                    BuildPhase::new(format!("cargo build --release --target {target}"));
+
+                if let Some(name) = RustProvider::get_app_name(app)? {
+                    // Copy the binary out of the target directory
+                    build_phase.add_cmd(format!("cp target/{target}/release/{name} {name}"));
+                }
+
+                build_phase
+            }
+            None => {
+                let mut build_phase = BuildPhase::new("cargo build --release".to_string());
+
+                if let Some(name) = RustProvider::get_app_name(app)? {
+                    // Copy the binary out of the target directory
+                    build_phase.add_cmd(format!("cp target/release/{name} {name}"));
+                }
+
+                build_phase
+            }
         };
 
+        build_phase.add_cache_directory(CARGO_GIT_CACHE_DIR.to_string());
+        build_phase.add_cache_directory(CARGO_REGISTRY_CACHE_DIR.to_string());
+
+        if RustProvider::get_app_name(app)?.is_some() {
+            // Cache target directory
+            build_phase.add_cache_directory(CARGO_TARGET_CACHE_DIR.to_string());
+        }
+
         Ok(Some(build_phase))
     }
 
     fn start(&self, app: &App, env: &Environment) -> Result<Option<StartPhase>> {
-        if let Some(toml_file) = RustProvider::parse_cargo_toml(app)? {
-            let name = toml_file.package.name;
+        let name = RustProvider::get_app_name(app)?;
 
+        if let Some(name) = name {
             let start_phase = match RustProvider::get_target(app, env)? {
-                Some(target) => {
-                    let binary_file = format!("./target/{target}/release/{name}");
+                Some(_) => {
+                    let binary_file = format!("./{name}");
                     let mut start_phase = StartPhase::new(format!("./{name}"));
 
                     start_phase.run_in_slim_image();
@@ -82,7 +113,7 @@ impl Provider for RustProvider {
 
                     start_phase
                 }
-                None => StartPhase::new(format!("./target/release/{name}")),
+                None => StartPhase::new(format!("./{name}")),
             };
 
             Ok(Some(start_phase))
@@ -103,6 +134,15 @@ impl Provider for RustProvider {
 }
 
 impl RustProvider {
+    fn get_app_name(app: &App) -> Result<Option<String>> {
+        if let Some(toml_file) = RustProvider::parse_cargo_toml(app)? {
+            let name = toml_file.package.name;
+            Ok(Some(name))
+        } else {
+            Ok(None)
+        }
+    }
+
     fn get_target(_app: &App, env: &Environment) -> Result<Option<String>> {
         // All the user to use the default target instead of compiling with musl
         if !env.is_config_variable_truthy("NO_MUSL") {
diff --git a/src/providers/zig.rs b/src/providers/zig.rs
index 89a5010..908daa3 100644
--- a/src/providers/zig.rs
+++ b/src/providers/zig.rs
@@ -33,11 +33,7 @@ impl Provider for ZigProvider {
     }
 
     fn install(&self, app: &App, _env: &Environment) -> Result<Option<InstallPhase>> {
-        let mut phase = InstallPhase {
-            cmds: None,
-            only_include_files: None,
-            paths: None,
-        };
+        let mut phase = InstallPhase::default();
         if app.includes_file(".gitmodules") {
             phase.add_cmd("git submodule update --init".to_string());
         }


Test patch:
diff --git a/tests/docker_run_tests.rs b/tests/docker_run_tests.rs
index eec16ff..e8727c2 100644
--- a/tests/docker_run_tests.rs
+++ b/tests/docker_run_tests.rs
@@ -356,7 +356,7 @@ fn test_rust_custom_version() {
         },
         &DockerBuilderOptions {
             name: Some(name.clone()),
-            quiet: false,
+            quiet: true,
             ..Default::default()
         },
     )
diff --git a/tests/generate_plan_tests.rs b/tests/generate_plan_tests.rs
index b029f05..3ad44e6 100644
--- a/tests/generate_plan_tests.rs
+++ b/tests/generate_plan_tests.rs
@@ -15,7 +15,14 @@ fn simple_gen_plan(path: &str) -> BuildPlan {
 #[test]
 fn test_node() -> Result<()> {
     let plan = simple_gen_plan("./examples/node");
-    assert_eq!(plan.install.unwrap().cmds, Some(vec!["npm ci".to_string()]));
+    assert_eq!(
+        plan.install.clone().unwrap().cmds,
+        Some(vec!["npm ci".to_string()])
+    );
+    assert_eq!(
+        plan.install.unwrap().cache_directories,
+        Some(vec!["/root/.npm".to_string()])
+    );
     assert_eq!(plan.build.unwrap().cmds, None);
     assert_eq!(plan.start.unwrap().cmd, Some("npm run start".to_string()));
 
@@ -69,9 +76,29 @@ fn test_node_custom_version() -> Result<()> {
     Ok(())
 }
 
+#[test]
+fn test_node_monorepo() -> Result<()> {
+    let plan = simple_gen_plan("./examples/node-monorepo");
+    assert_eq!(
+        plan.install.clone().unwrap().cmds,
+        Some(vec!["yarn install --frozen-lockfile".to_string()])
+    );
+    assert_eq!(
+        plan.install.unwrap().cache_directories,
+        Some(vec!["/usr/local/share/.cache/yarn/v6".to_string()])
+    );
+    assert_eq!(plan.build.unwrap().cmds, None);
+
+    Ok(())
+}
+
 #[test]
 fn test_yarn() -> Result<()> {
     let plan = simple_gen_plan("./examples/node-yarn");
+    assert_eq!(
+        plan.install.unwrap().cmds,
+        Some(vec!["yarn install --frozen-lockfile".to_string()])
+    );
     assert_eq!(
         plan.build.unwrap().cmds,
         Some(vec!["yarn run build".to_string()])
@@ -118,6 +145,14 @@ fn test_yarn_custom_version() -> Result<()> {
 #[test]
 fn test_pnpm() -> Result<()> {
     let plan = simple_gen_plan("./examples/node-pnpm");
+    assert_eq!(
+        plan.install.clone().unwrap().cmds,
+        Some(vec!["pnpm i --frozen-lockfile".to_string()])
+    );
+    assert_eq!(
+        plan.install.unwrap().cache_directories,
+        Some(vec!["/root/.cache/pnpm".to_string()])
+    );
     assert_eq!(
         plan.build.unwrap().cmds,
         Some(vec!["pnpm run build".to_string()])
@@ -332,8 +367,16 @@ fn test_pin_archive() -> Result<()> {
 #[test]
 fn test_custom_rust_version() -> Result<()> {
     let plan = simple_gen_plan("./examples/rust-custom-version");
-    let cmd = format!("cargo build --release --target {}-unknown-linux-musl", ARCH);
-    assert_eq!(plan.build.unwrap().cmds, Some(vec![cmd]));
+    assert_eq!(
+        plan.build.unwrap().cmds,
+        Some(vec![
+            format!("cargo build --release --target {}-unknown-linux-musl", ARCH),
+            format!(
+                "cp target/{}-unknown-linux-musl/release/rust-custom-version rust-custom-version",
+                ARCH
+            )
+        ])
+    );
     assert_eq!(
         plan.setup
             .unwrap()
@@ -350,8 +393,16 @@ fn test_custom_rust_version() -> Result<()> {
 #[test]
 fn test_rust_rocket() -> Result<()> {
     let plan = simple_gen_plan("./examples/rust-rocket");
-    let cmd = format!("cargo build --release --target {}-unknown-linux-musl", ARCH);
-    assert_eq!(plan.build.unwrap().cmds, Some(vec![cmd]));
+    assert_eq!(
+        plan.build.unwrap().cmds,
+        Some(vec![
+            format!("cargo build --release --target {}-unknown-linux-musl", ARCH),
+            format!(
+                "cp target/{}-unknown-linux-musl/release/rocket rocket",
+                ARCH
+            )
+        ])
+    );
     assert!(plan.start.clone().unwrap().cmd.is_some());
     assert_eq!(
         plan.start.clone().unwrap().cmd.unwrap(),
@@ -371,7 +422,10 @@ fn test_rust_rocket_no_musl() -> Result<()> {
     )?;
     assert_eq!(
         plan.build.unwrap().cmds,
-        Some(vec!["cargo build --release".to_string()])
+        Some(vec![
+            "cargo build --release".to_string(),
+            "cp target/release/rocket rocket".to_string()
+        ])
     );
     assert!(plan
         .start
@@ -379,7 +433,7 @@ fn test_rust_rocket_no_musl() -> Result<()> {
         .unwrap()
         .cmd
         .unwrap()
-        .contains("./target/release/rocket"));
+        .contains("./rocket"));
     assert!(plan.start.unwrap().run_image.is_none());
 
     Ok(())